From Polling to Production: How I Upgraded My Biometric Integration with Gate Control, Auto-Enrollment, and 24/7 Reliability

ruby dev.to

A follow-up to my previous post on connecting a ZK fingerprint device to Rails. This is the upgrade story — what broke, what I rebuilt, and what the system looks like now.

👉 Read the original post first: Connecting a Biometric Fingerprint Device to a Rails Web App Using Python


The Problem with "Good Enough"

When I published the first biometric post, the system worked. The Python bridge polled the device, forwarded punches to Rails, attendance appeared on the dashboard.

But "worked" and "production-ready" are different things.

Here's what the system looked like in practice after a few weeks of real gym use:

  • The bridge only ran when staff remembered to start it. They didn't always remember.
  • The bridge sent every historical punch on every restart. January records were being re-sent in March.
  • Expired members could still walk in. The DENIED label in the dashboard was cosmetic — the door opened anyway.
  • Enrolling a new member required two separate manual operations — entering them in the software, then going to the device and registering their fingerprint, then manually creating a mapping record in the database.
  • The gym's spare Android phone running the bridge had adware that would randomly kill background processes.
  • ADMS (the device's built-in cloud push protocol) wouldn't connect — SSL/HTTP mismatch between the ZK firmware and Cloudflare/Render.

None of these were bugs in the traditional sense. The code did what I wrote it to do. The problem was the gap between what the code did and what the business actually needed.

This post is about closing that gap.


What I Built (The Upgrade Summary)

Feature Before After
Bridge startup Manual Auto-starts silently on Windows boot via VBScript
Historical re-sends Happened on every restart Filtered by date.today() — today's punches only
Expired member gate access Door opened, just logged DENIED Finger template deleted from device — door physically stays closed
Member enrollment 3 manual steps across 2 systems Single "Enroll Fingerprint" button in member profile
Bridge reliability Gym laptop (unreliable, staff-dependent) Dual: laptop (primary) + Android phone (backup)
Cloudflare/Render SSL ADMS blocked by SSL mismatch Flexible SSL on Cloudflare — HTTP from device, HTTPS to Render
Finger template backup None Templates stored as JSON in DB before deletion
Auto-restore on renewal None Sync script restores templates from DB on next run

Part 1: Fixing the "Today Only" Problem

The original bridge used an in-memory last_sent set to deduplicate. This worked within a single session but had a critical flaw: every time the script restarted, the set was empty, so all historical records were re-sent to Rails.

The fix is one line, but it changes everything:

# bridge.py

from datetime import date

while True:
    today = date.today()  # re-evaluated every loop iteration
    attendances = conn.get_attendance()
    device_sn = conn.get_serialnumber()

    for att in attendances:
        # Only process today's records
        if att.timestamp.date() != today:
            continue

        key = f"{att.user_id}-{att.timestamp}"
        if key in last_sent:
            continue

        payload = {
            "compcode": COMP_CODE,
            "user_id": att.user_id,
            "timestamp": att.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
            "device_sn": device_sn
        }
        send_to_rails(payload)
        last_sent.add(key)

    time.sleep(POLL_INTERVAL_SECONDS)
Enter fullscreen mode Exit fullscreen mode

Two important details:

  1. date.today() is called inside the loop, not outside it. This means if the script runs past midnight, it automatically shifts to the new date without a restart.
  2. The deduplication set last_sent still prevents duplicate sends within a session. Rails-side deduplication handles the cross-session case.

The result: restart the bridge at any point during the day and it picks up exactly from today's records. No January punches appearing in May.


Part 2: Auto-Start on Windows — The Silent Background Process

The original .bat file approach had a visible terminal window. Staff would close it. Attendance would stop. Nobody would notice until the owner called.

The fix is a .vbs file placed in the Windows Startup folder:

' start_bridge.vbs
' Place in: shell:startup

Set objShell = CreateObject("WScript.Shell")
objShell.Run "cmd /c cd C:\Users\Admin\Desktop\biometric_bridge && python bridge.py", 0, False
objShell.Run "cmd /c cd C:\Users\Admin\Desktop\biometric_bridge && python enroll_api.py", 0, False
Enter fullscreen mode Exit fullscreen mode

The 0 as the third parameter means hidden window — no terminal, no visible process. Staff can't accidentally close what they can't see.

Both the attendance bridge and the enrollment API (covered later) start silently on every boot.

To verify it's running without a terminal window, check Task Manager → Details tab → look for python.exe. If it's there, the bridge is alive.

Preventing Sleep from Killing the Process

Windows sleep/hibernate will kill background processes even with the VBScript approach. Set these before deploying:

Settings → System → Power & Sleep
  Screen: Never
  Sleep: Never

Control Panel → Power Options → Choose what closing the lid does
  When I close the lid: Do nothing
Enter fullscreen mode Exit fullscreen mode

This is also where you tell staff: "This laptop is the gym's server. Do not close the lid. Do not shut it down." The mystique helps.


Part 3: The Gate Control Problem — Why DENIED Wasn't Enough

The original system logged DENIED in the database when an expired member scanned their finger. But the door still opened. The DENIED label was purely informational — the device itself didn't know anything had changed.

To physically prevent the door from opening for expired members, the change has to happen on the device itself, not in the database.

The ZK device controls the door relay. When a fingerprint is matched, the relay fires and the door opens. If no fingerprint template exists for a user, the device shows a red failure screen and the relay doesn't fire. No template = no access. No middleware required.

This is the cleanest possible implementation: delete the finger template from the device when a subscription expires, and restore it when they renew.

The Database Change

First, add two columns to trn_member_biometric_mappings to store the template data before deletion:

ALTER TABLE trn_member_biometric_mappings
  ADD COLUMN mbm_finger_template LONGTEXT,
  ADD COLUMN mbm_uid INT DEFAULT NULL;
Enter fullscreen mode Exit fullscreen mode

mbm_uid stores the ZK internal UID (different from device_user_id). mbm_finger_template stores the raw finger template bytes as a JSON array so we can restore them later.

The Sync Script

# sync_access.py

from zk import ZK
from zk.user import User
from zk.finger import Finger
import requests
from config import *

RAILS_API_BASE = "https://spine-fitness.com"
DEVICE_SN = "NFZ8253402448"


def get_access_status():
    """
    Fetch all mapped members and their subscription status from Rails.
    Returns list of {device_user_id, access, name, user_info} dicts.
    """
    try:
        # Wake Render from idle before the real request
        requests.get(RAILS_API_BASE, timeout=30)
    except:
        pass

    try:
        response = requests.get(
            f"{RAILS_API_BASE}/api/access_status",
            params={"compcode": COMP_CODE, "device_sn": DEVICE_SN},
            timeout=60
        )
        return response.json().get("users", [])
    except Exception as e:
        print(f"Could not fetch access status: {e}")
        return []


def save_template_to_rails(device_user_id, uid, templates):
    """
    Before deleting a user from the device, save their finger templates
    to the Rails DB so we can restore them on renewal.
    """
    try:
        template_data = [
            {
                "uid": t.uid,
                "fid": t.fid,
                "valid": t.valid,
                "template": list(t.template)  # bytes → list for JSON serialization
            }
            for t in templates
        ]
        requests.post(
            f"{RAILS_API_BASE}/api/biometric_mappings/save_template",
            json={
                "compcode": COMP_CODE,
                "device_user_id": device_user_id,
                "device_sn": DEVICE_SN,
                "uid": uid,
                "templates": template_data
            },
            timeout=60
        )
    except Exception as e:
        print(f"    Could not save template to Rails: {e}")


def restore_user_to_device(conn, user_info):
    """
    Re-add a previously blocked user to the device using their saved templates.
    Called when their subscription is renewed.
    """
    uid = user_info.get("uid")
    device_user_id = user_info.get("device_user_id")
    name = user_info.get("name", "Member")
    templates_data = user_info.get("templates")

    if not uid or not templates_data:
        print(f"    No stored template for {name} — needs manual re-enrollment")
        return

    try:
        user_obj = User(
            uid=int(uid),
            name=name[:24],
            privilege=0,
            password='',
            group_id='',
            user_id=str(device_user_id),
            card=0
        )
        fingers = [
            Finger(
                uid=int(uid),
                fid=t["fid"],
                valid=t["valid"],
                template=bytes(t["template"])
            )
            for t in templates_data
        ]
        conn.save_user_template(user=user_obj, fingers=fingers)
        print(f"    Restored: {name} (device_user_id={device_user_id})")
    except Exception as e:
        print(f"    Could not restore {name}: {e}")


def sync_device_access():
    print("Starting access sync...")

    users_status = get_access_status()
    if not users_status:
        print("No data from Rails — skipping sync")
        return

    # Build lookup keyed by device_user_id
    access_map = {str(u["device_user_id"]): u for u in users_status}

    zk = ZK(DEVICE_IP, port=DEVICE_PORT, timeout=DEVICE_TIMEOUT,
            password=0, force_udp=False, ommit_ping=False)

    try:
        conn = zk.connect()
        conn.disable_device()

        # Snapshot of current device state
        device_users = {str(u.user_id): u for u in conn.get_users()}
        templates = conn.get_templates()
        templates_by_uid = {}
        for t in templates:
            templates_by_uid.setdefault(t.uid, []).append(t)

        for device_user_id, status in access_map.items():
            access = status["access"]       # "ALLOW" or "DENY"
            name = status.get("name", "Unknown")
            user_info = status.get("user_info", {})

            if device_user_id in device_users:
                user = device_users[device_user_id]

                # Never touch admin/staff accounts (privilege=14)
                if user.privilege == 14:
                    continue

                if access == "DENY":
                    # Save templates to DB before deletion
                    user_templates = templates_by_uid.get(user.uid, [])
                    if user_templates:
                        save_template_to_rails(device_user_id, user.uid, user_templates)
                    conn.delete_user(uid=user.uid)
                    print(f"  BLOCKED: {name} (device_user_id={device_user_id})")

                # access == "ALLOW" and user exists on device → nothing to do
                else:
                    pass

            else:
                # User not currently on device
                if access == "ALLOW" and user_info.get("uid") and user_info.get("templates"):
                    # Previously blocked, subscription renewed → restore
                    restore_user_to_device(conn, user_info)

        conn.enable_device()
        print("Access sync complete.")

    except Exception as e:
        print(f"Sync error: {e}")
        import traceback
        traceback.print_exc()
    finally:
        try:
            conn.enable_device()
            conn.disconnect()
        except:
            pass


if __name__ == "__main__":
    sync_device_access()
Enter fullscreen mode Exit fullscreen mode

The Rails API Endpoint

The sync script needs to know who is active and who isn't:

# app/controllers/api/access_status_controller.rb
class Api::AccessStatusController < ApplicationController
  skip_before_action :verify_authenticity_token

  def index
    compcode  = params[:compcode]
    device_sn = params[:device_sn]
    today     = Date.today

    mappings = TrnMemberBiometricMapping.where(
      mbm_compcode:  compcode,
      mbm_device_sn: device_sn,
      mbm_is_active: 'Y'
    )

    users = mappings.map do |m|
      has_active = TrnMemberSubscription
        .where(ms_compcode: compcode, ms_member_id: m.mbm_member_id)
        .where('ms_end_date >= ?', today)
        .exists?

      member = MstMembersList.find_by(id: m.mbm_member_id)

      {
        device_user_id: m.mbm_device_user_id,
        access:         has_active ? "ALLOW" : "DENY",
        name:           member&.mmbr_name || "Unknown",
        user_info: {
          uid:            m.mbm_uid,
          device_user_id: m.mbm_device_user_id,
          name:           member&.mmbr_name || "Unknown",
          templates:      m.mbm_finger_template ? JSON.parse(m.mbm_finger_template) : nil
        }
      }
    end

    render json: { status: true, users: users }
  end
end
Enter fullscreen mode Exit fullscreen mode

And the template save endpoint:

# app/controllers/api/biometric_mappings_controller.rb
def save_template
  mapping = TrnMemberBiometricMapping.find_by(
    mbm_compcode:       params[:compcode],
    mbm_device_user_id: params[:device_user_id],
    mbm_device_sn:      params[:device_sn]
  )

  return render json: { status: false, message: "Mapping not found" } unless mapping

  mapping.update!(
    mbm_uid:             params[:uid],
    mbm_finger_template: params[:templates].to_json
  )

  render json: { status: true }
end
Enter fullscreen mode Exit fullscreen mode

Running Sync Automatically

The sync runs once on bridge startup and once every midnight — embedded as a background thread inside bridge.py:

# bridge.py (updated)

import threading
from sync_access import sync_device_access

SYNC_HOUR = 0  # midnight

def sync_scheduler():
    print("Running initial access sync...")
    sync_device_access()

    while True:
        now = datetime.now()
        if now.hour == SYNC_HOUR and now.minute == 0:
            print("Midnight sync starting...")
            sync_device_access()
            time.sleep(61)  # prevent double-trigger within same minute
        time.sleep(30)

def main():
    sync_thread = threading.Thread(target=sync_scheduler, daemon=True)
    sync_thread.start()

    # ... rest of bridge.py unchanged
Enter fullscreen mode Exit fullscreen mode

What the Member Experiences

Member with expired subscription scans finger:
  → Device shows red ✗ screen
  → "Unregistered ID" or "Access Denied"  
  → Door relay does NOT fire
  → Gate stays closed

Member renews subscription in Rails:
  → Next midnight sync runs
  → Rails returns "ALLOW" for this member
  → Sync finds stored templates in DB
  → Templates re-uploaded to device
  → Next scan: green ✓, door opens
Enter fullscreen mode Exit fullscreen mode

The gate control is entirely device-side. Even if the laptop is off, the internet is down, or the Rails app is unreachable — if a template was deleted, the door stays closed. The device enforces access independently.


Part 4: Fingerprint Enrollment from the Web UI

The original system required three separate manual steps to enroll a new member's fingerprint:

  1. Add the member in the web app
  2. Go to the biometric device, navigate the menu, register their finger manually
  3. Note the device-assigned user ID, come back to the computer, manually insert a row into trn_member_biometric_mappings

Step 3 was where it always broke. Staff would forget the device ID. They'd write it on a scrap of paper and lose it. The mapping table would get out of sync.

The fix: a local Flask API running on the gym laptop alongside bridge.py. The web app's member profile page sends an HTTP request to localhost:5000/enroll, which creates the device user and triggers the enrollment screen — all from a single button click.

Why localhost?

The enrollment API only needs to be reachable from the gym laptop, because enrollment always happens in person at the gym. The browser making the request and the Flask server are on the same machine. No ngrok, no tunnels, no external exposure.

# enroll_api.py

from flask import Flask, request, jsonify
from flask_cors import CORS
from zk import ZK
from zk.user import User
import requests as req
from config import *

app = Flask(__name__)
CORS(app)

RAILS_API_BASE = "https://spine-fitness.com"
DEVICE_SN      = "NFZ8253402448"


def get_next_uid_and_user_id(conn):
    users = conn.get_users()
    if not users:
        return 1, '1'
    max_uid      = max(u.uid for u in users)
    existing_ids = [int(u.user_id) for u in users if str(u.user_id).isdigit()]
    max_user_id  = max(existing_ids) if existing_ids else 0
    return max_uid + 1, str(max_user_id + 1)


@app.route('/health', methods=['GET'])
def health():
    return jsonify({'status': True, 'message': 'Enrollment API running'})


@app.route('/enroll', methods=['POST'])
def enroll():
    data        = request.json
    member_id   = data.get('member_id')
    member_name = data.get('member_name', 'Member')
    compcode    = data.get('compcode', 'SF')

    zk = ZK(DEVICE_IP, port=DEVICE_PORT, timeout=DEVICE_TIMEOUT,
            password=0, force_udp=False, ommit_ping=False)

    try:
        conn = zk.connect()
        conn.disable_device()

        new_uid, new_device_user_id = get_next_uid_and_user_id(conn)

        # Create user record on device (no finger template yet)
        user_obj = User(
            uid=new_uid,
            name=member_name[:24],
            privilege=0,
            password='',
            group_id='',
            user_id=new_device_user_id,
            card=0
        )
        conn.save_user_template(user=user_obj, fingers=[])

        # Trigger fingerprint enrollment screen on device
        conn.enable_device()
        conn.enroll_user(uid=new_uid, temp_id=0)

        # Save mapping to Rails
        req.post(
            f"{RAILS_API_BASE}/api/biometric_mappings",
            json={
                'compcode':       compcode,
                'member_id':      str(member_id),
                'device_user_id': new_device_user_id,
                'device_sn':      DEVICE_SN,
                'uid':            new_uid
            },
            timeout=30
        )

        return jsonify({
            'status':         True,
            'message':        f'Device ready — ask {member_name} to scan finger now!',
            'device_user_id': new_device_user_id
        })

    except Exception as e:
        return jsonify({'status': False, 'message': str(e)}), 500
    finally:
        try:
            conn.enable_device()
            conn.disconnect()
        except:
            pass


if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000, debug=False)
Enter fullscreen mode Exit fullscreen mode

The UI: Fingerprint Section on Member Profile

The member edit page now has a biometric section that shows the current enrollment status and relevant actions:

<%# In member_list/add_member.html.erb %>

<% if @member && @member.id %>
  <% mapping = TrnMemberBiometricMapping.find_by(
       mbm_compcode: session[:loggedUserCompCode],
       mbm_member_id: @member.id.to_s,
       mbm_is_active: 'Y'
     ) %>

  <div class="form-group row" style="margin-top: 20px;">
    <div class="col-md-12">
      <div class="card" style="padding: 15px;">
        <h5>Biometric Fingerprint</h5>

        <% if mapping %>
          <p>
            <span class="badge badge-success">✓ Enrolled</span>
            Device User ID: <strong><%= mapping.mbm_device_user_id %></strong>
          </p>
          <button type="button"
            onclick="enrollFinger(<%= @member.id %>, '<%= @member.mmbr_name %>')"
            class="btn btn-warning btn-sm">
            Re-enroll Finger
          </button>
          <button type="button"
            onclick="removeMapping(<%= mapping.id %>)"
            class="btn btn-danger btn-sm">
            Remove Mapping
          </button>
        <% else %>
          <p class="text-muted">No fingerprint enrolled.</p>
          <button type="button"
            onclick="enrollFinger(<%= @member.id %>, '<%= @member.mmbr_name %>')"
            class="btn btn-primary btn-sm">
            👆 Enroll Fingerprint
          </button>

          <%# Manual mapping for members already on device %>
          <div style="margin-top: 10px;">
            <input type="text" id="manual_device_uid"
              class="form-control form-control-sm"
              placeholder="Device User ID (manual)" style="width: 200px; display: inline;"/>
            <button type="button"
              onclick="saveManualMapping(<%= @member.id %>, '<%= @member.mmbr_name %>')"
              class="btn btn-secondary btn-sm">
              Save Manual Mapping
            </button>
          </div>
        <% end %>

        <div id="enroll-status" class="mt-2"></div>
      </div>
    </div>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode
function enrollFinger(memberId, memberName) {
  $("#enroll-status").html('<span class="text-info">⏳ Checking enrollment service...</span>');

  // First ping localhost to confirm we're on the gym laptop
  $.ajax({
    url: 'http://localhost:5000/health',
    type: 'GET',
    timeout: 3000,
    success: function() {
      doEnroll(memberId, memberName);
    },
    error: function() {
      $("#enroll-status").html(
        '<span class="text-warning">⚠ Enrollment only works from the gym laptop.</span>'
      );
    }
  });
}

function doEnroll(memberId, memberName) {
  $.ajax({
    url: 'http://localhost:5000/enroll',
    type: 'POST',
    contentType: 'application/json',
    data: JSON.stringify({ member_id: memberId, member_name: memberName, compcode: 'SF' }),
    timeout: 30000,
    success: function(resp) {
      if (resp.status) {
        $("#enroll-status").html('<span class="text-success">✓ ' + resp.message + '</span>');
        setTimeout(function(){ location.reload(); }, 4000);
      } else {
        $("#enroll-status").html('<span class="text-danger">Error: ' + resp.message + '</span>');
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

The health check before enrollment is important — it gives a clear message when someone tries to enroll from home rather than a confusing network error.


Part 5: The Android Backup Bridge

The gym's spare Android phone was already on 24/7 (used for YouTube/music). I put it to work as a backup bridge using Termux.

# Termux setup
pkg update && pkg upgrade -y
pkg install python -y
pip install pyzk requests   # note: pyzk, NOT zk

# Create startup script (runs on phone boot via Termux:Boot)
mkdir -p ~/.termux/boot
cat > ~/.termux/boot/start-bridge.sh << 'EOF'
#!/data/data/com.termux/files/usr/bin/sh
termux-wake-lock
cd /data/data/com.termux/files/home
python3 bridge.py &
EOF
chmod +x ~/.termux/boot/start-bridge.sh
Enter fullscreen mode Exit fullscreen mode

The key insight: pip install zk installs Zipkin (a distributed tracing library). The correct package is pip install pyzk. Easy mistake to make, hard to debug if you don't know to look for it.

The termux-wake-lock command prevents Android from killing Termux when it's backgrounded — critical for a bridge that needs to run continuously while YouTube plays in the foreground.

Set battery optimization to "Unrestricted" for Termux in Android Settings to prevent aggressive power management from terminating the process.

Dual Bridge Architecture

With both bridges running:

Phone on  + Laptop off → attendance works via phone ✓
Phone off + Laptop on  → attendance works via laptop ✓
Both on               → both send, Rails deduplication ignores duplicates ✓
Enter fullscreen mode Exit fullscreen mode

The Rails duplicate check (same member + same minute) means double-sends are silently ignored. Running two bridges simultaneously is safe.


Part 6: The ADMS Investigation (And Why I Abandoned It)

I spent significant time trying to make ADMS (the device's built-in cloud push protocol) work. ADMS would have eliminated the need for the Python bridge entirely — the device would push attendance directly to Rails.

The investigation revealed the exact failure chain:

  1. DNS resolution worked — after changing the device's DNS from the router (192.168.31.1) to Google's (8.8.8.8), the device could resolve spine-fitness.com
  2. Port 80 was reachablecurl http://spine-fitness.com/iclock/getrequest from the same network returned OK
  3. Cloudflare activated successfully — Flexible SSL mode set, domain proxied through Cloudflare
  4. Rails ADMS controller was deployed — routes for /iclock/cdata and /iclock/getrequest were live and returning OK
  5. The device still never connected — zero /iclock/ requests appeared in Render logs even after DNS fix and reboot

The most likely root cause: the router has per-device firewall rules blocking outbound HTTP from IoT/embedded devices (MAC address filtering or device category classification). The laptop and phone on the same WiFi could reach the internet fine; the ZK device could not — despite identical network configuration.

The ADMS controller code remains in the codebase for future use if the router configuration ever changes:

# app/controllers/api/adms_controller.rb
class Api::AdmsController < ApplicationController
  skip_before_action :verify_authenticity_token

  def getrequest
    render plain: "OK"
  end

  def handshake
    render plain: "OK"
  end

  def receive
    body = request.body.read
    Rails.logger.info "ADMS received: #{body}"

    sn_match  = body.match(/SN=([^\s&\r\n]+)/i)
    device_sn = sn_match ? sn_match[1] : 'NFZ8253402448'

    body.each_line do |line|
      line = line.strip
      next if line.empty?
      next if line.start_with?("ATTLOG")
      next unless line.split("\t")[0].to_s.match?(/\A\d+\z/)

      parts          = line.split("\t")
      device_user_id = parts[0].to_s.strip
      timestamp      = parts[1].to_s.strip
      punch_time     = Time.zone.parse(timestamp) rescue nil

      next unless punch_time
      next if punch_time.to_date < Date.today

      process_attendance(device_user_id, punch_time, device_sn)
    end

    render plain: "OK"
  end

  private

  def process_attendance(device_user_id, punch_time, device_sn)
    # same logic as BiometricAttendancesController
  end
end
Enter fullscreen mode Exit fullscreen mode

If you're starting fresh and your router doesn't filter IoT devices, ADMS is the cleaner solution — no bridge needed at all.


The Updated Architecture

                    ┌─────────────────────────────┐
                    │   ZK Fingerprint Device      │
                    │   192.168.31.151:4370        │
                    │   Door relay connected        │
                    └──────────┬──────────────────┘
                               │ pyzk TCP
              ┌────────────────┼────────────────┐
              │                │                │
              ▼                ▼                │
 ┌────────────────┐  ┌──────────────────┐       │
 │  Python Bridge │  │  Enroll API      │       │
 │  bridge.py     │  │  enroll_api.py   │       │
 │  (gym laptop)  │  │  Flask :5000     │       │
 │                │  │  (gym laptop)    │       │
 │  + sync_access │  └────────┬─────────┘       │
 │    thread      │           │ localhost POST   │
 └───────┬────────┘           │                 │
         │                    │                 │
         │ HTTPS POST         │                 │
         ▼                    ▼                 │
 ┌───────────────────────────────────────────┐  │
 │        Cloudflare (Flexible SSL)          │  │
 └───────────────────┬───────────────────────┘  │
                     │ HTTPS                     │
                     ▼                           │
 ┌───────────────────────────────────────────┐  │
 │     Ruby on Rails (Render)                │  │
 │                                           │  │
 │  POST /api/biometric_attendances          │  │
 │  POST /api/biometric_mappings             │  │
 │  POST /api/biometric_mappings/save_template│  │
 │  GET  /api/access_status                  │  │
 └───────────────────┬───────────────────────┘  │
                     │                           │
                     ▼                           │
 ┌───────────────────────────────────────────┐  │
 │     MySQL (CleverCloud)                   │  │
 │                                           │  │
 │  trn_member_attendances                   │  │
 │  trn_member_biometric_mappings            │  │
 │    + mbm_finger_template (LONGTEXT)       │  │
 │    + mbm_uid (INT)                        │  │
 │  trn_member_subscriptions                 │  │
 └───────────────────────────────────────────┘  │
                                                 │
 ┌───────────────────────────────────────────┐  │
 │  Android Phone (Termux backup bridge)     │──┘
 │  Same WiFi, same device, different host   │
 └───────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key Engineering Decisions in Retrospect

1. Delete the template, not just a flag.
The first instinct is to add an is_blocked flag somewhere and check it in a middleware layer. But that still involves a network call at scan time — and the network can be down. Deleting the template makes the device itself the enforcement layer. No network, no middleware, no latency. The device just can't match a finger that isn't there.

2. Store templates before deletion.
This is the part that's easy to skip and catastrophic if you do. Always save the template bytes to the database before calling conn.delete_user(). The sync script saves first, deletes second — if the save fails, the delete doesn't happen.

3. Never touch admin accounts (privilege=14).
The sync script explicitly skips any user with privilege == 14. These are device admins — staff, trainers, the owner. They should always have access regardless of subscription status. One unguarded delete_user on an admin account during a sync run would be a very bad day.

4. The enrollment API health check.
Before sending the enroll command, the browser pings localhost:5000/health. If it doesn't respond, the user sees "Enrollment only works from the gym laptop" instead of a generic network error. Small UX detail, but it prevents a lot of confused support calls.

5. Render cold starts are real.
The sync script pings the Rails root URL before the actual access_status request. Render's free tier sleeps after inactivity. Without the warmup ping, the first real request would time out, the sync would abort, and nobody would get blocked or restored. The warmup adds ~2 seconds but makes the sync reliable.


Current State

The system has been running in production for several months. The gym has:

  • Zero attendance records missed due to bridge downtime (dual bridge coverage)
  • Members with expired subscriptions physically blocked at the door
  • New member enrollment taking under 30 seconds from web form to working fingerprint
  • The gym owner checking the live attendance dashboard from home on their phone

The notebook is in a drawer somewhere. Nobody's opened it.


🔗 Live: spine-fitness.com
💻 Source: github.com/imlakshay08/spine-fitness-gym-management-system

If you've dealt with biometric hardware integration or the ZK device ecosystem — I'd love to compare notes in the comments.

Source: dev.to

arrow_back Back to Tutorials