Work

Jun 2, 2026 - Aug 10, 2026
Business Tools
Industrial Automation
Hardware
Automation
Python
FastAPI
Vue 3
TypeScript
Tauri
PLC
Modbus
SSE

A distributed production-line control system built for a real factory floor, carrying an e-commerce order all the way to a shipped unit. An order platform pulls from third-party ERPs, persists, and dispatches by rule; a machine-side node runs on the on-site industrial PC and drives the PLC, servo rollers, VFD conveyor, and label printer — feeding by order, counting by barcode, printing, packing, sorting, and reporting status back; an aggregation hub polls every machine into one snapshot and streams it to the floor dashboard and mobile over SSE. Python + FastAPI on the back end, Vue 3 + TypeScript on the front, shipped as a Tauri desktop app with the backend compiled into a Nuitka sidecar — the whole stack runs offline on the local network.

Line overview — conveyor, automatic packer, and the inclined sorting chutes

An order shouldn’t stop at the screen. It should reach the machine and leave as a shipped unit.


Background

Online operations and the factory floor used to be disconnected: orders lived in the ERP, production lived in people’s hands. Workers cross-checked SKUs against exported spreadsheets, stuck labels by hand, and counted units by eye — one order at a time, then scroll to the next row. The machines themselves were already automated. The packer, the conveyor, the cylinders were all there; nothing was telling them which unit of which order was due next.

This project fills exactly that gap. I built a distributed production-line control system from scratch: an upper layer that takes orders, a middle layer that schedules machines, and a lower layer that drives the PLC and serial devices directly — so an order stays stateful, traceable, and replayable from pull and dispatch through feeding, barcode counting, label printing, packing, and sorting. It doesn’t run in the cloud; it runs on the industrial PC bolted to the machine, and it has to keep shipping when the network drops.

Project Info
RoleSolo build · on-site commissioning
Timeline2026.06 — 2026.08
TypeIndustrial control · distributed
DeploymentOffline LAN · desktop delivery
10
line states
4
device actors
2
production modes
3
deployment tiers
The line in production — operator screen, automatic packer, and a finished parcel on the conveyor

The line in production. The operator screen is mounted on the packer itself; a bagged and sealed unit drops onto the green conveyor and heads for the sorting stage. Screen contents blurred.


System Architecture
Order platform · intake

FastAPI · PostgreSQL
Pluggable multi-ERP layer
Node registry / heartbeat / tokens
Dispatch rules · ordered label printing

pullpersistdispatchsync
HTTP · Token
Machine node · execution

Production worker loop
Single-writer supervisor + journal
Pure-function line state machine
Hardware actor layer · local JSON store

/jobs/production/plc/servo/conveyor/printer
snap7 · Modbus · serial
Physical devices · the floor

PLC (cylinders / output points)
Servo rollers (feed slots)
VFD conveyor (speed / ramping)
Label printer · industrial scanner
Laser marker · sorter

A fourth piece, the aggregation hub, spans every machine: it polls each node’s read-only endpoints into one snapshot, streams it to the floor dashboard over SSE, lets operators manage feed slots per machine from a phone, and forwards writes back to the right node.

PRODUCTION FLOW
Feed Pusher cylinder 1 Transport VFD conveyor 2 Scan Code reader 3 Count Dedup window 4 Discard NoRead eject 5 Print Label printer 6 Pack Sealer + air 7 Sort Servo rollers 8 ← Order queue Bagged and shipped →
One unit's full path down the line. Every stage maps to a set of parameters tunable from the browser and a Test button.
The layer being driven

This is what the software eventually lands on: the packer and its label printer, the cylinder that pushes a parcel off the line, and the reader whose every report maps to exactly one count.

Automatic packer with the label printer mounted on top and the film roll Automatic packer with the label printer mounted on top and the film roll
Pneumatic pusher and the inclined sorting chute Pneumatic pusher and the inclined sorting chute
Industrial code reader and its light array above the conveyor Industrial code reader and its light array above the conveyor

01HARDWARE ACTOR
Locking exclusive hardware inside single-threaded actors
Single-writer actor · bounded queue · futures · unified lifecycle

Serial ports, PLC connections, and printer drivers are exclusive resources. Two HTTP requests writing the same RS485 bus don’t produce an error — they produce garbage, and in the physical world garbage means the conveyor starts at the wrong frequency.

So every device class is wrapped in a single-threaded actor: callers can only submit to a bounded queue and get a future back; a full queue is rejected rather than piling up; a timeout abandons the wait without interrupting the command already in flight. The drivers themselves need no locks at all.

Device debug panel: PLC, photoelectric sensor, servo rollers, VFD conveyor, and printer each tested independently

Every actor has a matching debug entry — connect and exercise one device without running production.

Devices behind actors
› PLC — output pulses, connection checks
› Servo rollers — single and batch feed commands
› VFD conveyor — start/stop, frequency, jog, fault reset
› Printer — submit job, cancel job
What it buys
› Exactly one writer on the bus, always
› Requests queue instead of race; timing is predictable
› Config hot-reload rides the same queue, no torn state
› One close path on exit — no dangling serial handles
02CRASH RECOVERY
After a crash, the line has to know where it stopped
Single-writer supervisor · command queue · journal replay

Factory PCs get unplugged, and Windows reboots itself at 3am. Software can restart — but the goods are still sitting on the conveyor. If the restarted process doesn’t know the last order was seven units in, the operator has to clear the whole line and start over.

Line state therefore collapses to a single writer: every change enters a queue as a command and is applied serially, and each transition is written to a journal before it takes effect. On restart the journal replays to the pre-crash snapshot, and the operator confirms and resumes from that point instead of from zero.

COMMAND PATH
API / workersubmits a command, receives a future
Bounded queueserialized; rejects when full; per-command deadline
Reducerpure function validating the transition
Journalpersisted first, visible second
Snapshotsingle source of truth for UI and dashboard
SINGLE WRITER + JOURNAL
HTTP API Production worker Scheduled jobs Bounded queue serial · rejects when full reducer pure validation journal persisted first snapshot single truth illegal transition → rejected restart → replay
Every state change becomes a command through one bounded queue, applied serially. The journal is written before the change is visible, so a restart can replay to the moment before the crash.
03STATE MACHINE
Hierarchical states: “stopped” is never just stopped
10 states · 4 families · pure reducer · illegal transitions rejected

On the floor, “the line stopped” is four completely different events: someone hit pause, material ran out, a crash replay is waiting for confirmation, or a device faulted. Expressing all four with one isRunning boolean leaves the operator guessing at the screen. Instead the state is split into 10 hierarchical values across 4 families; the UI decides button availability by family, and the reducer is a pure function that touches no hardware — the entire state logic is testable on a laptop with no machine attached.

idle
› ready — cleared to start
› completed — this batch is done
active
› starting — devices coming up
› running — producing normally
› pausing — pause accepted, winding down
paused
› operator — manual pause
› material — out of stock, held
› recovery — replayed, awaiting confirmation
faulted
› runtime — software-side failure
› device — hardware reported an error
LINE STATE MACHINE
reset complete_run idle ready completed active starting running pausing paused operator material recovery ← journal replay lands here faulted runtime device begin_run pause / block resume fault
Line state is split into 10 hierarchical values across 4 families. The reducer is pure and rejects illegal transitions; after a crash the journal replays into paused.recovery and waits for an operator to confirm.
The console: every field variable on one page

Every value that needs tuning on site has an input and a Test button next to it — control points, pulse widths, cylinder extend and retract, scan dedup windows, conveyor frequency and ramps, roller direction and speed per logistics channel. Machine identifiers, internal addresses, and product SKUs are redacted.

Production flow: feed, transport, scan trigger, counting, discard, print and pack, sort Production flow: feed, transport, scan trigger, counting, discard, print and pack, sort
Feed slots, sealer, and cylinders — control points and timing Feed slots, sealer, and cylinders — control points and timing
Sorting routes and the VFD conveyor's speed and ramping parameters Sorting routes and the VFD conveyor's speed and ramping parameters
04FIELD PROTOCOL
Industrial scanners don’t speak standard HTTP
Compatibility gateway · raw TCP · captured signal log

The scanner’s “HTTP mode” doesn’t actually follow the spec — its headers carry a stray path line with no colon, and standard frameworks reject the request outright. I put a compatibility gateway in front of the business port: it detects whether the incoming bytes are an HTTP message or raw TCP, forwarding the former and treating the latter as a barcode straight into the counter. On the device side, that’s still just one IP and one port to configure.

The gateway also archives every inbound frame as raw bytes, hex, and multi-encoding decodes, surfaced in a “raw signal” debug panel. Swapping in a new scanner on site no longer needs a packet capture tool — you can see exactly what it sent from the browser.

Why a long-lived TCP connection, not UDP

Every report maps to one count. A silently dropped UDP packet raises no error — it just undercounts the order by one unit, discovered after the box has shipped, with nothing in the logs to explain it. When failure must be visible, one persistent connection is a cheap price.

Photoelectric sensor on the rail and a labeled parcel on the conveyor

The sensor decides the unit is in position, the reader reports its barcode — together they decide whether this one counts. Label redacted.

SCANNER GATEWAY
Code reader one IP, one port Gateway sniff the frame tolerate bad headers HTTP frame raw TCP internal FastAPI Counter one report = one unit raw bytes / hex / multi-encoding archive raw-signal debug panel
The device only needs one IP and one port. The gateway decides for itself whether the bytes are an HTTP message or raw TCP, and archives the raw frames for on-screen troubleshooting.
05TUNING
Two production modes, one timing snapshot each
Pipeline vs. serial · tuned from the browser · hardware config stays global

The same machine runs two ways. Pipeline keeps several orders flowing down the conveyor for throughput; serial runs one order at a time for control. Their timing needs are completely different — yet both share the same PLC addresses and serial settings. So parameters are split by dimension: process timing is snapshotted per mode, hardware and connection config stays global and singular. Switching modes swaps the whole timing set instead of re-entering it field by field.

Per mode
› Pipeline gap and max in-flight
› Scan dedup window / idle timeout
› Conveyor frequency, start and stop ramps
› Cylinder and bag-assist timing
› Print dispatch and completion timeouts
Global
› PLC address and output point
› Serial port and baud rate
› Node identity and upstream address
› Printer selection
How it lands
› Saved from the on-site web UI, effective immediately
› Written back to config, drivers refreshed
› No more copying config files machine to machine
› Code defaults let a fresh machine boot as-is
PIPELINE VS SERIAL
same instant Pipeline throughput first order 1 order 2 order 3 3 orders in flight · bounded by max-inflight and gap Serial control first order 1 order 2 order 3 1 order in flight time
Two ways to run the same machine. Pipeline keeps several orders flowing at once; serial runs one at a time. Each mode keeps its own timing snapshot, while hardware config stays global.
06OFFLINE FIRST
A dropped network is not a reason to stop shipping
Local signature verification · disk queue · background retry · idempotent dedup

Unstable factory networks are the norm, so the whole system is designed to be self-sufficient on the LAN: jobs live on the machine, and the production loop makes no outbound calls. Tokens issued by the account service are verified locally against a cached public key, so operators can still sign in and start a shift when the service is unreachable. Records that need reporting go into a disk-backed queue, retried by a background thread and deduplicated server-side by idempotency key — everything reconciles once connectivity returns, and the line keeps running in the meantime.


The floor dashboard sits beside the machine, showing live machine status and feed slots

The dashboard sits right next to the machine — visible by looking up. Screen contents blurred.

One screen overhead, one in your hand

For a single machine the node console is enough; for several it isn’t. The aggregation hub adds a layer above every node: a background poller hits each machine’s read-only endpoints concurrently on a fixed interval, composes one in-memory snapshot, persists history, and streams it to the dashboard over SSE.

Polling only touches endpoints that don’t drive hardware. Calls like conveyor status, which physically open an RS485 port just to answer, are explicitly excluded from the high-frequency loop — monitoring has no business disturbing the production bus.

The phone is the second screen on the floor

Almost every change to a feed slot happens next to the machine, not at a desk. An operator finishes loading and confirms it, spots a jam and flags a fault, switches orders and edits the SKU and quantity, or test-feeds a single unit while commissioning. If all of that requires walking back to a computer, the system just gets routed around.

So the hub renders these actions as a mobile-first page: join the floor’s local network and operate any machine from a phone, with writes forwarded through the hub to the right node and reads coming from the same snapshot the dashboard uses. The dashboard is read-only and needs no login; the phone does.

The phone is the second screen on the floor

07DESKTOP SHIP
From “it runs” to “it installs”
Tauri shell · Nuitka sidecar · Job Objects · NSIS / .app

Nobody on the floor is going to open a terminal. The release build fuses three things into one double-clickable app: Tauri supplies the native window and process management, the Vue front end is built by Vite straight into the WebView, and FastAPI is compiled by Nuitka into a standalone sidecar shipped alongside — no Python runtime on the machine.

On launch, Tauri starts the sidecar and only shows the window once the local port answers; on exit it shuts the sidecar down, and on Windows a Job Object binds the backend to the app’s lifetime so closing the window can’t leave a process quietly driving the conveyor. Release builds also disable the API docs and skip registering debug routes.

Bringing a new machine online
01Register the node in the hub, copy the init command
02Run it on the new machine to write identity and upstream address
03Run the start script — backend and console come up together
04Pick printer, serial port, and production parameters in the browser
05Enter only the machine address in the hub; the name is read automatically
08EXTENDING
Node types still being extended
Laser marking · scan sorting — still in development at handover, not yet fully deployed

Stations beyond the packer plug in through the same “node” model. Laser marking brought an interesting inversion: the vendor system is pull-based — once its vision system recognizes a product, it connects to us and asks for the content, rather than us pushing content to it. Control, content, and template each get their own TCP link, and the driver layer is pluggable, so the vendor system, a direct board connection, and pure simulation are interchangeable with zero changes above.

Laser marking node
› Pull-based protocol: the device requests content
› Three TCP links — control, content, template
› Pluggable drivers: vendor system · direct board · simulation
› Queue, template binding, and UI debuggable cross-platform
Scan sorting node
› Barcodes over TCP, captured images over FTP
› Stores device timestamp, server timestamp, raw fields
› Every valid scan creates one sorting task
› Claim → confirm complete / retry on failure
PULL-BASED LASER LINK
Laser node queue · template binding Vendor system + device vision recognizes the product control: start, switch, status content: the device asks what to mark template: the device asks which one Pluggable drivers: vendor system · direct board · simulation
The inversion: once the laser marker’s vision recognizes a product, it connects to us and asks for the content — we are the side being queried. Control, content, and template each get their own link.

What this project taught me

The physical world doesn't retry

On the web a failed request is a refresh away. On a line, one wrong output pulse is a scrapped unit. The design center of gravity moves from 'how do we recover' to 'how do we make the illegal state unrepresentable'.

Field devices ignore the spec

The datasheet claims HTTP support; the actual frames don't comply. What works is not asking the vendor to change, but keeping a compatibility layer and a raw-signal archive on your own side.

Delivery shape is a feature

Working isn't shippable. One command to bring a machine online, a double-click installer, no orphaned process after closing the window — these decide whether the system gets used or gets routed around.

3
architecture tiers
10
line states
4
device actors
2
production modes
SSE
dashboard stream
Offline
keeps producing

This page is a redacted overview: no source code, repository, real customer or vendor names, device addresses, credentials, or licensing internals.