At a glance
| Hand-written code | 5,488 lines in 9 files |
| Python (server) | 2,686 lines · 1 file · 129 functions and methods, 3 classes |
| JavaScript (PWA + setup console + service worker) | 1,199 lines · 3 files |
| HTTP surface | 31 JSON API routes, all mirrored under a session-protected prefix, plus 5 server-rendered pages |
| ERP queries | 3 configurable read-only SQL queries (workcenters, workcenter groups, previous/current/next job) plus built-in part and part-image lookups |
| Model registry | per building + workcenter + part key, with candidates/, archive/ and latest/, crash-safe promotion |
| Training queue states | 9 (ready → picked_up → training → trained → candidate_ready → evaluating → promoted, plus evaluation_failed, failed) |
| Production model served | YOLOv8n detector, 320 px input, 2 classes (good / defect), compiled for a Hailo NPU on the Pi |
| Measured station inference rate | median 30.0 FPS (range 17.4–42.0) across 2,526 heartbeat samples |
| Logged traffic | 19,241 requests, 99.5 % answered 2xx |
| Inspections counted from station heartbeats | 1,580 parts, 46 flagged defect |
| Automated tests | none (see Testing) |
| Version control | not a git repository (empty .git folder); file dates run June → September 2026 |
Lines of code by language
Excluded: the vendored hls.js player (543 KB minified), Python bytecode caches, all runtime storage (dataset videos, model weights, heartbeat history, logs, state files), test model bundles, bitmap screenshots and the logo, and the .env file (not opened).
| Language | Lines | Files |
|---|---|---|
| Python | 2,686 | 1 |
| CSS | 1,264 | 1 |
| JavaScript | 1,199 | 3 |
| PowerShell | 249 | 2 |
| Windows batch | 83 | 1 |
| Shell | 7 | 1 |
| Total | 5,488 | 9 |
Not counted above: 1,628 lines of Markdown (7 files: a README and five contract documents for the Pi, server and training machines, plus a bug list) and 33 lines of JSON configuration (the SQL query file and the PWA manifest).
Lines of code by module
The server is a single Python file. These modules are contiguous sections of it, plus the front-end files.
| Module | Lines | What it is |
|---|---|---|
| HTTP handler, routing, model registry | 798 | Request parsing, auth gates, every route, model lookup / publish / promote |
| Server-rendered pages | 377 | Login, code entry, fleet dashboard shell and setup console HTML |
| Dataset ingest and training queue | 414 | Upload validation, canonical paths, manifests, queue upsert, browser previews, byte-range serving |
| Core settings and utilities | 303 | Environment settings, config loading, ODBC connection, SQL guards, slugs, timestamps, file lock |
| ERP access | 216 | Schema discovery, configured queries, part detail, part images, part search |
| Heartbeats and part reports | 188 | Station heartbeat validation, online status, per-part pass/defect counters |
| Storage layer | 145 | Folder layout, atomic JSON writes, crash recovery of model promotion |
| Email sign-in | 124 | One-time codes over SMTP, sessions |
| CV unit registry | 76 | Camera stations assigned to workcenters, with stream settings |
| Entry point | 45 | Startup checks and the threaded server |
| Fleet dashboard (JS) | 727 | Plant → group → workcenter drill-down, live stream, uploads, part override |
| Setup console (JS) | 419 | CV unit editor, coverage map, training dashboard, dataset browser |
| Service worker (JS) | 53 | App-shell cache for the installable PWA |
| Styles (CSS) | 1,264 | Dark dashboard theme for desktop and phone |
| Watchdog and launch scripts | 339 | Health-check watchdog, Task Scheduler installer, restart script, shell launcher |
The problem
Mursix put camera-based inspection on the shop floor using Raspberry Pi units with an AI accelerator hat. Each unit runs a small object detector that sorts parts into good and defect. That raised three problems the Pi could not solve by itself:
- It needs to know what it is looking at. The right model depends on which part is running on the press or assembly cell right now, and that lives in the Plex ERP. The Plex ODBC driver is Windows-only and needs credentials, so it does not belong on every Pi.
- Models need data, and the data needs order. Training clips have to land in one predictable place, tagged with workcenter, part and job, with held-out validation clips kept apart from training clips.
- Models have to come back safely. A new model should only reach the floor after it has been checked, and a failed update must never leave a station without a model.
The ERP Connector is the one Windows PC that holds the ODBC driver and acts as the system of record. Pis, phones and the nightly training machine all talk to it over plain HTTP on the plant network.
┌──────────────────────────────┐
Raspberry Pi CV station │ ERP Connector PC │ Plex ERP
(camera + Hailo NPU, │ │ (cloud, via ODBC)
YOLOv8n good/defect) │ ERP proxy ──── read-only SQL ├────────► workcenters
│ │ │ jobs, parts
│ GET job/part context │ Upload intake ─► dataset/ │ part images
├─────────────────────►│ │ train | validation │
│ POST heartbeat (≈5 s)│ ▼ │
├─────────────────────►│ Training queue (JSON) ◄─────┼──── Nightly training PC
│ POST video+metadata │ │ reads queue, trains,
├─────────────────────►│ Model registry │ publishes candidate,
│ GET latest model │ candidates/ archive/ latest/│ promotes after eval
│◄─────────────────────┤ │
│ │ Fleet dashboard PWA ◄───────┼──── phones / PCs
│ HLS live preview ────┼──────────── (direct, not │ (email sign-in)
│ │ proxied) ─────────┼───►
│ Watchdog (Task Scheduler) │
└──────────────────────────────┘1. ERP proxy (Plex over ODBC)
The connector turns ERP lookups into simple JSON endpoints so the Pi never needs a database driver.
- Configured queries. Workcenters, workcenter groups and per-workcenter jobs come from SQL kept in a JSON config file, not in code, so they can be changed without a redeploy. The jobs query uses window functions (
ROW_NUMBER() OVER (PARTITION BY …)) across job, operation and operation-status views to pick the previous, current and next job for a workcenter and the part key of the current one. - Part enrichment. Each job row is enriched with part number, name, revision and the part's photos. The photo list is ordered default image first, then newest; the public image URL is built from the stored filename. A per-request cache stops the same part being looked up twice.
- Part search by number or name, with an optional revision filter, for when an operator needs to override the part the ERP reports.
- Schema discovery. Table and column search over the ODBC catalog, plus an ad-hoc query endpoint, were used to find the right Plex views while the queries were being written.
SQL safety. Every statement passes a read-only guard: it must start with SELECT or WITH, may not contain a semicolon, and may not contain DML/DDL keywords (INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, EXEC, MERGE, CREATE). Configured templates are checked before values are substituted and again after. Substituted values are SQL-quoted with doubled single quotes; part search uses true ? parameters. ODBC failures are returned as HTTP 502 so a client can tell "ERP is down" from "bad request".
2. Upload intake and dataset builder
Captures arrive through a four-step upload API, so a half-sent video never reaches the dataset:
initcreates an upload record and an intake folder per device.videostreams the raw bytes (capped at a configurable maximum, 2 GB by default) and writes them atomically (temp file, then rename).metadatastores the sidecar JSON.completevalidates and ingests.
Validation on complete. The video exists and is non-empty, the metadata exists and has every required field (schema version, capture ID, device ID, recorded time, building, workcenter, part key, video filename, ERP snapshot), the building is the one supported site, the workcenter exists in the live ERP list, the video filename equals <capture_id>.mp4, the dataset role is train or validation, and the capture ID is not already in the dataset. Every path component is checked for /, \ and ...
On failure the whole upload folder moves to quarantine/, the upload record is marked quarantined, the error is logged, and the client gets a clear error message.
On success the files move into a canonical layout:
dataset/<building>/<workcenter_slug>/<part_key>/<train|validation>/raw/<YYYY>/<YYYY-MM-DD>/<capture_id>.mp4|.jsonA per-role manifest records capture count and last capture time, and the training queue is updated. The workcenter slug is lowercase, with every run of non-alphanumeric characters turned into one underscore. All timestamps are normalized to UTC; a timestamp without a time zone is rejected.
The training identity is building + workcenter + part, not the job. The job number is kept for traceability only, because one workcenter runs many parts over time and one part runs on many jobs.
3. Training queue
The server keeps an explicit queue in a JSON file instead of making the trainer scan folders.
- Upsert rule. A new capture joins the open queue item (
ready,picked_up,trainingortrained) for the same building, workcenter and part. If there is none, a new item is created. Each item tracks all new capture IDs and also separate train and validation ID lists. - State machine. The nightly trainer moves items through
ready → picked_up → training → trained → candidate_ready → evaluating → promoted, or toevaluation_failed/failed, with an optional message. Unknown states are rejected. - Validation clips are a first-class upload type. They are stored apart from training clips so the trainer can score the current model and the candidate on the same held-out clips, and promote only a candidate that beats the current model without losing defect recall. The contract documents spell out this acceptance rule. The evaluation itself runs on the training machine, which is not part of this project.
4. Model registry and delivery
Models are stored per building / workcenter / part:
models/<building>/<workcenter_slug>/<part_key>/
candidates/<version>/ staged by publish
archive/<version>/ every version ever published
latest/ what the Pi downloads- Publish copies a bundle from the server's staging area into both
archive/andcandidates/and marks the queue itemcandidate_ready. It does not replacelatestunless the caller asks for auto-promotion. The source folder must resolve inside the server's storage root, so a caller cannot publish from an arbitrary path. A bundle must containmodel.onnx,labels.jsonandmodel_metadata.json. - Promote moves a candidate, or any archived version (which makes rollback one call), into
latest. Promotion requires a compiled.heffile for the Pi's Hailo accelerator, so an ONNX-only model can never reach a station that cannot run it. - Crash-safe swap. The new bundle is fully built in
latest_new, the currentlatestis renamed tolatest_old,latest_newis renamed tolatest, and thenlatest_oldis deleted. If the final rename fails, the old model is put back. At startup the server scans for leftoverlatest_old/latest_newfolders and finishes or rolls back any promotion a crash interrupted. At no point is there a window with nolatestmodel. - Lookup by workcenter and part returns the metadata and a download URL for every file in
latest. When a.hefis present it is marked as the preferred runtime artifact, and the reported format is changed to match. If no model exists for that exact part, the server returns 404. It never falls back to another part's model.
The model itself. The deployed bundle is a YOLOv8n detector at 320 × 320 input with two classes, good and defect. It was exported to ONNX (12.1 MB) and compiled with the Hailo Model Zoo toolchain into a 4.8 MB HEF with on-chip NMS. Quantization was calibrated on up to 1,024 sample images from both classes, and the compile took 2 min 34 s across three contexts. Training and compilation happen off this server; the connector stores, versions and serves the result.
5. Station telemetry (heartbeats and per-part counts)
Each Pi posts a heartbeat every few seconds with its ERP context, UI state, camera list, inference status (FPS, target FPS, confidence threshold, top label and score, model status), recording state, upload queue depth, disk usage and session counters.
- Validation. Required sections must be present and must be objects. The workcenter must exist in the ERP.
- Online status. A station is online when
(configurable). The dashboard sorts online stations first. A heartbeat file that fails to parse is logged as an error instead of silently disappearing from the dashboard.
- Per-part inspection report. Stations send running session counters (inspected, good, defect). The server turns them into durable totals per part and per station using deltas against the previous heartbeat:
Running totals survive Pi restarts and part changeovers without double counting.
- Concurrency. Read-previous / update-report / write-latest runs under an exclusive OS file lock (
msvcrton Windows,fcntlelsewhere), so overlapping heartbeats from threads or processes cannot corrupt the totals.
6. Fleet dashboard (installable PWA)
A dark, responsive web app served by the connector and installable on phones, with a service worker that caches the app shell and always goes to the network for API calls.
- Sign-in. Company-domain email plus a 6-digit one-time code sent over SMTP (STARTTLS or SSL). Codes come from
secrets.randbelowand expire after 10 minutes. Sessions use a 32-byte URL-safe token in anHttpOnly,SameSite=Laxcookie and last 12 hours. Expired codes and sessions are purged on every auth call. Email sign-in can be switched off for local use. - Plant level. Workcenter count, online and offline stations, groups with at least one station online, a bar per workcenter group, and a "needs attention" list.
- Group level. A station table for every workcenter in a Plex workcenter group.
- Workcenter level.
- The current ERP part, with its photo, previous/current/next job and revision.
- A live view when a CV unit is assigned: an HLS stream played natively on iOS or through hls.js elsewhere, with automatic recovery from network and media errors. When there is no stream, the Plex part photo is shown instead. If several units share a workcenter, the one with a live heartbeat wins.
- Phone capture upload. Record or pick a video, choose training or validation, add notes, and send it through the same four-step upload API as the Pi. The metadata carries an ERP snapshot and the uploader's identity.
- Manual part override by Plex part search, for when the ERP's current job is not what is on the line.
- Live refresh. Heartbeats are polled every 5 s. When the station reports a different part than the one on screen, the ERP context reloads by itself.
Setup console (admin only)
- CV unit registry. Display name, device ID (matched against heartbeats), assigned workcenter (or none), camera count, active flag, and stream URL / format / size. A workcenter can have zero, one or several units.
- Workcenter coverage across the plant.
- Training dashboard. Queue totals by status and captures by role.
- Training-set browser. Search and filter every capture by role, workcenter, part, device or job, and play it in the browser. Videos not already in H.264 are transcoded once with FFmpeg (libx264, CRF 23,
+faststart) into a cached preview. A lock prevents duplicate transcodes, and the cache is rebuilt when the source is newer. Playback supports RFC 7233 byte ranges, so scrubbing works on phones.
7. API
All routes return JSON with an ok flag. Errors map to status codes: 400 for validation errors, 401 for auth, 404 for missing items, 501 for unconfigured queries, 502 for ERP/ODBC failures. Every /api/... route is also reachable as /app/api/... behind a session, and setup / admin routes additionally require the admin role.
| Area | Routes |
|---|---|
| Health | GET /health (ODBC driver and data-source presence, storage root, queue size) |
| ERP | GET /api/workcenters[?group=], GET /api/workcenter-groups, GET /api/workcenters/{code}/jobs, GET /api/parts/search?q=&revision= |
| Discovery | GET /api/discovery/tables, GET /api/discovery/columns, POST /api/query (read-only) |
| Telemetry | POST /api/metrics/heartbeat, GET /api/metrics/latest |
| Uploads | POST /api/uploads/init, POST /api/uploads/{id}/video, POST /api/uploads/{id}/metadata, POST /api/uploads/{id}/complete, GET /api/uploads[/{id}] |
| Training | GET /api/training/queue[?status=], POST /api/training/queue/{id}/status |
| Models | GET /api/models/by-workcenter/{code}?part_key=, GET /api/models/latest, GET /api/models/download/{code}/{part}/{file}, POST /api/models/publish, POST /api/models/promote |
| CV units | GET /api/cv-units, GET/POST /api/setup/cv-units, POST /api/setup/cv-units/delete |
| Admin | GET /api/admin/training-dashboard, GET /api/admin/dataset/captures, GET …/captures/{id}/video, GET …/captures/{id}/preview |
| Pages | /app, /app/login, /app/verify, /app/logout, /app/setup |
Request bodies are bounded: JSON at 1 MB, video at the upload cap. A missing or non-numeric Content-Length, malformed JSON, or a JSON value that is not an object all return 400. Static files are served only from inside the static folder.
8. Reliability engineering
- Atomic writes everywhere. All JSON state (queue, uploads, CV units, sessions, manifests, reports) and uploaded video bytes are written to a temp file and renamed into place.
- Crash-safe model promotion with startup recovery (section 4).
- Quarantine for bad uploads instead of deleting them.
- Structured logs. Every request goes to a JSON-lines access log. Every error goes to an error log with status, type, message and full traceback.
- Watchdog (PowerShell, installed as a Windows scheduled task):
- A named mutex ensures only one watchdog runs.
- It adopts an already-healthy server instead of starting a duplicate.
- It starts the server hidden and waits up to 30 s for
/health. - It probes
/healthevery 15 s and kills the process tree after 4 straight failures. - It restarts the server 5 s after a crash, or 10 s after a failed start.
- It archives stdout and stderr logs with a timestamp on every restart.
- Task Scheduler settings:
- Starts at sign-in, and optionally at machine boot before anyone signs in (S4U logon, so no stored password).
- A repair trigger fires every 5 minutes for ten years.
- Restarts up to 999 times at 1-minute intervals, with no execution time limit, and ignores duplicate instances.
- Restart script. Frees the port by killing whatever listens on it, byte-compiles the server as a syntax check (a broken build leaves the server stopped instead of half-running), starts one instance, and waits for a healthy
/health. - Fail-fast startup. A missing or invalid query config gives a clear message and exit code 2 instead of a stack trace.
9. The multi-machine contract
Half of the project is the written contract that lets three machines be built separately:
- Pi: deterministic capture IDs (
cap_<UTC date>_<UTC time>_<workcenter_slug>_<device>_<seq>), a metadata sidecar schema, upload with retry and a persistent pending queue (never silently drop a capture), and a local model cache keyed by workcenter and part. - Live stream: H.264 at 640 × 480, 5–15 FPS, about 0.5–2 Mbit/s, over HLS for iOS compatibility. The stream goes straight from the Pi to the viewer and is never proxied, so total bandwidth ≈ bitrate × concurrent viewers lands on the LAN rather than on the connector.
- Server: naming rules, folder layout, validation and quarantine rules, the manifest and queue formats, the model-metadata schema, and "never return another part's model".
- Nightly trainer: read the queue, train per building + workcenter + part, version models as
<YYYY-MM-DD>_<HHMMSS>_utc, copy the bundle into the server's staging area, publish, evaluate against validation clips, and promote. If training fails,latestis left alone.
Testing and measured results
There is no automated test suite. Verification was done by hand against the running server, guided by a written bug list with explicit checks (missing config, Content-Length: abc, an oversized JSON body, malformed JSON, a corrupt heartbeat file, publishing from outside the storage root, and killing the server mid-promotion). The server's own error log shows those checks being run and handled: 400 for the bad Content-Length, the oversized body and the malformed JSON, and a rejection for the outside-root publish. Every item on that list is fixed in the current code.
Figures below come from the server's logs and state files:
| Measure | Result |
|---|---|
| Requests logged (June → October 2026) | 19,241: 19,140 × 200, 14 × 206 (range), 20 × 303 (redirect) → 99.5 % success |
| Errors | 23 × 502 (ERP/ODBC: mostly views tried during query development), 32 × 404 (mostly "no model for this part yet"), 6 × 400, 1 × 500 (a truncated video that could not be transcoded) |
| Traffic mix | 11,635 watchdog health checks · 3,717 station heartbeats · 2,504 dashboard refreshes · 148 ERP job lookups · 101 model lookups · 83 full model downloads |
| Station heartbeat snapshots | 2,722 from one production station over 13 days |
| On-device inference rate | median 30.0 FPS against a 30 FPS target (min 17.4, max 42.0) over 2,526 samples |
| Top detection score while a part was in view | median 0.88 over 68 samples |
| Parts inspected and counted through heartbeats | 1,580 across three parts: 1,534 good, 46 defect (97.1 % pass) |
| Model compile (Hailo toolchain) | 2 min 34 s, 3 contexts, ONNX 12.1 MB → HEF 4.8 MB |
| Watchdog | detected a crash and restarted the server to healthy in 7 s |
The pass and defect counts are the detector's own calls. No ground-truth labelling of those parts exists here, so they show the pipeline working, not classifier accuracy. The model metadata has no validation-accuracy figure recorded.
Honest status
- Working in production: the ERP proxy, heartbeats and per-part counters, model lookup and download to a live Pi station, the fleet dashboard with live HLS view, the watchdog, and crash-safe promotion. A real station downloaded and ran a Hailo-compiled model served by this connector.
- Built but lightly used: the upload → dataset → queue → publish → promote loop works end to end, but it has been exercised with only a handful of test captures (3 videos in the dataset, 3 queue items). The automated nightly trainer and the candidate-vs-current evaluation step described in the contracts are not part of this project and are not yet running; the deployed model was imported by hand.
- Security hardening is on the roadmap. The connector is built for a closed plant network today; device authentication and transport encryption are planned before any wider exposure.
- The admin role is a single configured account.
- It is fine behind the plant firewall. It needs per-device keys, TLS and real role checks before wider exposure.
- Simple storage. State lives in JSON files that are rewritten whole. That is fine at fleet scale (tens of stations), but only heartbeat aggregation is locked across processes.
- One file. The server is a single 2,700-line Python file on the standard-library HTTP server. That keeps deployment trivial (two pip packages), but it is overdue for splitting into modules.
- Drift. An earlier version wrote a per-heartbeat history archive; the current code keeps only the latest heartbeat per station plus the per-part reports. The telemetry figures above come from that older archive.
- No tests, no git history. Changes were made and verified by hand.