作品

2026年06月02日 至 2026年08月10日
业务系统
工业自动化
硬件
自动化
Python
FastAPI
Vue 3
TypeScript
Tauri
PLC
Modbus
SSE

面向真实车间的分布式产线控制系统,把电商订单一路送到机台出货。订单平台从第三方 ERP 拉单、落库并按规则分发;机台上位机在现场一体机上驱动 PLC、伺服滚筒、变频传送带与面单打印机,按订单出料、扫码计数、打印、打包、分拣并回传状态;聚合中枢轮询多台机台合成快照,通过 SSE 推送车间大屏与手机端。后端 Python + FastAPI,前端 Vue 3 + TypeScript,桌面端由 Tauri 承载并把后端经 Nuitka 编译成 sidecar,整套在局域网内离线自洽。

自动化产线全景:传送带、自动打包机与斜置分拣滑道

订单不该停在屏幕上,它应该一路走到机台,变成一件出库的货。


项目背景

在此之前,线上运营和车间生产之间是断开的:订单在 ERP 里,生产在人手上。工人拿着导出的表格核对 SKU、手动贴面单、靠肉眼数件数,一个订单做完再翻到下一行。机台本身是自动的——打包机、传送带、气缸都在,但没有任何东西告诉它们「现在该做哪张订单的第几件」。

这个项目补的就是这一段。我从零搭起一套分布式产线控制系统:上层接订单、中层调度机台、下层直接驱动 PLC 和串口设备,让一张订单从拉取、分发、出料、扫码计数、打印面单到打包分拣全程有状态、可追踪、可回放。它不跑在云上,而是装在车间那台一体机里,断网也要继续出货。

Project Info
角色独立开发 · 现场调试
周期2026.06 — 2026.08
类型工业上位机 · 分布式控制
部署局域网离线 · 桌面端交付
10
产线状态
4
设备 Actor
2
生产模式
3
层级部署
生产中的机台:操作屏、自动打包机与传送带上的成品包裹

生产中的机台。操作屏就架在打包机上,商品装袋封口后落到绿色传送带,往分拣段走。屏幕内容已虚化。


System Architecture
订单平台 · 接单调度

FastAPI · PostgreSQL
多 ERP 抽象接入层
节点注册 / 心跳 / Token
订单分发规则 · 面单顺序打印

拉单落库分发状态同步
HTTP · Token
机台上位机 · 现场执行

生产 worker 主循环
单写者 Supervisor + Journal
纯函数产线状态机
硬件 Actor 层 · 本地 JSON 存储

/jobs/production/plc/servo/conveyor/printer
snap7 · Modbus · 串口
物理设备 · 车间现场

PLC(气缸 / 输出点)
伺服滚筒(上料槽出料)
变频传送带(调速 / 加减速)
面单打印机 · 工业读码器
激光打标机 · 分拣器

另有一层聚合中枢横跨多台机台:轮询各节点的只读接口合成快照, 通过 SSE 秒级推送车间大屏,手机端按机台管理上料槽,写操作再转发回对应节点。

PRODUCTION FLOW
出料 气缸推料杆 1 传送 变频传送带 2 扫码 工业读码器 3 计数 去重窗口 4 废弃 NoRead 踢出 5 打印 面单打印机 6 打包 封装器 + 吹袋 7 分拣 伺服滚筒 8 ← 订单队列 落袋出货 →
一件货在产线上的完整路径。每一段都对应一组可在网页里调整的参数和一个「测试」按钮。
被驱动的那一层

软件最终要落到这些东西上:打包机与面单打印机、把包裹推下线的气缸、以及每次上报都对应一次计数的读码器。

自动打包机、机身上的面单打印机与卷膜 自动打包机、机身上的面单打印机与卷膜
气缸推料杆与斜置分拣滑道 气缸推料杆与斜置分拣滑道
传送带上方的工业读码器与补光模组 传送带上方的工业读码器与补光模组

01HARDWARE ACTOR
把独占硬件关进单线程 Actor
Single-writer actor · 有界队列 · Future · 统一生命周期

串口、PLC 连接、打印机驱动都是独占资源:两个 HTTP 请求同时写一条 RS485 总线,得到的不是报错而是乱码,而乱码在物理世界里意味着传送带以错误频率启动。

所以每类设备都被封进一个单线程 actor:外部只能往有界队列里 submit,拿回一个 Future;队列满了直接拒绝而不是无限堆积;超时可放弃等待但不会打断已在执行的那一条。设备驱动本身因此不需要写任何锁。

设备调试面板:PLC、激光传感器、伺服滚筒、变频传送带、打印机逐个独立调试

每个 actor 都有一个对应的调试入口,可以脱离生产流程单独连、单独试。

被 Actor 化的设备
› PLC —— 输出点脉冲、连接自检
› 伺服滚筒 —— 单条 / 批量出料指令
› 变频传送带 —— 启停、调频、点动、故障复位
› 打印机 —— 提交打印、取消 job
换来的性质
› 总线上永远只有一个写者
› 请求排队而非争抢,时序可预测
› 配置热更新走同一条队列,不撕裂状态
› 退出时统一 close,不留悬挂串口
02CRASH RECOVERY
崩溃之后,产线得知道自己停在哪
单写者 Supervisor · 命令队列 · Journal 回放

车间的电脑会被人拔电、会被 Windows 半夜重启。软件崩了可以重启,但物料还卡在传送带上——如果重启后的程序不知道「上一单做到第 7 件」,操作工只能清空整条线重来。

产线状态因此收敛成唯一写者:所有变更以命令形式进队列串行处理,每次迁移先落盘 journal 再生效。进程重启时回放 journal 恢复到崩溃前的快照,由操作工确认后从断点续产,而不是从零开始。

COMMAND PATH
API / worker提交命令,拿到 Future
有界队列串行化,满则拒绝,超时有 deadline
reducer纯函数校验迁移合法性
journal先持久化,再对外可见
snapshot唯一真相,供大屏与前端读取
SINGLE WRITER + JOURNAL
HTTP API 生产 worker 定时任务 有界队列 串行 · 满则拒绝 reducer 纯函数校验 journal 先落盘 snapshot 唯一真相 非法迁移 → 直接拒绝 崩溃重启 → 回放
所有状态变更都收敛成命令,经同一条有界队列串行处理。先落 journal 再对外可见,因此重启后能回放到崩溃前那一刻。
03STATE MACHINE
分层状态机:停机也要分清是谁停的
10 个状态 · 4 个族 · 纯函数 reducer · 非法迁移直接拒绝

「产线停了」在现场是四件完全不同的事:人按了暂停、缺料自动挂起、崩溃恢复后等待确认、设备报故障。用一个 isRunning 布尔量表达它们,结果就是操作工看着屏幕猜。状态被拆成带层级的 10 个点、归入 4 个族,前端按族决定按钮可用性,reducer 是不碰硬件的纯函数——整套状态逻辑可以在没有机台的笔记本上跑测试

idle · 空闲族
› ready —— 可以开工
› completed —— 本批已做完
active · 运行族
› starting —— 设备就位中
› running —— 正常产出
› pausing —— 收到暂停,收尾中
paused · 暂停族
› operator —— 人工暂停
› material —— 缺料挂起
› recovery —— 崩溃回放后待确认
faulted · 故障族
› runtime —— 软件侧异常
› device —— 硬件报错
LINE STATE MACHINE
reset complete_run idle ready completed active starting running pausing paused operator material recovery ← journal 回放落点 faulted runtime device begin_run pause / block resume fault
产线状态被拆成 10 个带层级的点、归入 4 个族。reducer 是纯函数,非法迁移直接拒绝;崩溃重启后由 journal 回放落到 paused.recovery,等人工确认再续产。
上位机:把现场变量摊开在一页里

现场要调的每一个量都有对应的输入框和一个「测试」按钮——控制点、脉冲时长、气缸推出与收回、扫码去重窗口、传送带频率与加减速、按物流渠道的滚筒转向与转速。界面中的机台标识、内网地址与商品 SKU 已脱敏。

生产流程:出料、传送、扫码触发、计数、废弃、打印打包、分拣逐段配置 生产流程:出料、传送、扫码触发、计数、废弃、打印打包、分拣逐段配置
出料槽、封装器与气缸的控制点与时序配置 出料槽、封装器与气缸的控制点与时序配置
分拣路由与变频传送带的调速、加减速参数 分拣路由与变频传送带的调速、加减速参数
04FIELD PROTOCOL
工业读码器发的不是标准 HTTP
兼容网关 · 裸 TCP 长连接 · 原始信号留档

现场读码器的「HTTP 模式」并不严格守协议——header 里会多出没有冒号的路径行,标准框架直接拒收。我在业务端口前面加了一层兼容网关:自动判断进来的是 HTTP 报文还是裸 TCP 数据,是前者转发给后端,是后者整段当条码送进计数器。设备侧因此只需填一个 IP 和一个端口。

网关同时把每一次进来的原始 bytes、HEX 和多编码解码结果留档,前端「原始信号调试」直接可见。现场换一台新读码器,不用抓包工具也能在网页里看清它到底发了什么。

为什么是 TCP 长连接,不是 UDP

每一次上报都对应一次计数。UDP 静默丢包不会报错,只会让这一单少计一件——货发出去了才发现少了,而日志里干干净净什么都查不到。这类「失败必须可见」的场景,宁可多花一条长连接。

导轨上的光电传感器与传送带上贴了面单的成品包裹

光电传感器判定物件到位,读码器上报条码——两者共同决定这一件算不算数。面单已脱敏。

SCANNER GATEWAY
工业读码器 只填一个 IP 一个端口 兼容网关 判别报文形态 容忍不合规 header HTTP 报文 裸 TCP 内部 FastAPI 计数器 一次上报 = 一件 原始 bytes / HEX / 多编码解码留档 前端「原始信号调试」
设备侧只需要填一个 IP 和一个端口。网关自己判断进来的是 HTTP 报文还是裸 TCP 数据,同时把原始字节留档给前端排查。
05TUNING
两种生产模式,各存一套节拍参数
流水线批产 / 单单串行 · 网页调参 · 硬件配置全局共用

同一台机器有两种跑法:流水线让多单在传送带上并行流动追求吞吐,单单串行一次只做一单换取可控。两者需要的节拍完全不同——但它们共用同一套 PLC 地址和串口配置。所以参数按维度拆开:流程参数按模式各存一份快照,硬件与连接配置全局唯一。切模式即换整套节拍,不必逐项重填。

按模式独立
› 流水线间隔与在制上限
› 扫码去重窗口 / 空扫超时
› 传送带频率、启停加减速
› 气缸与吹袋辅助时序
› 打印下发与完成超时
全局共用
› PLC 地址与输出点
› 串口号与波特率
› 节点身份与主系统地址
› 打印机选择
落地方式
› 现场网页保存即生效
› 写回配置并刷新驱动
› 不再逐台复制配置文件
› 留有代码默认值可直接开机
PIPELINE VS SERIAL
同一时刻 流水线批产 吞吐优先 单 1 单 2 单 3 3 单同时在制 · 受在制上限与间隔约束 单单串行 可控优先 单 1 单 2 单 3 1 单在制 时间
同一台机器的两种跑法。流水线让多单在传送带上并行流动,单单串行一次只做一单——两者的节拍参数各存一套快照,硬件配置全局共用。
06OFFLINE FIRST
断网不能成为停产的理由
本地公钥验签 · 磁盘队列 · 后台重试 · 幂等去重

工厂的网不稳定是常态。整套系统按局域网自洽设计:任务落在机台本地,生产循环不依赖任何外网调用。账号中心签发的 token 由节点用公钥本地验签,认证中心暂时不可达也能登录开工;需要上报的记录先进磁盘队列,后台线程重试补传,服务端按幂等键去重——网络恢复后自动对齐,中间这段时间产线照跑。


车间大屏与机台并排摆放,实时显示各机台状态与上料槽总览

大屏就摆在机台旁边,抬头即可看到。屏幕内容已虚化处理。

抬头一块屏,手里一块屏

单台机器的状态在上位机上看就够了,多台就不行。聚合中枢在所有节点之上加了一层:后台按固定间隔并发轮询各机台的只读接口,合成一份内存快照并落库,大屏通过 SSE 秒级接收。

轮询只碰不触发硬件的接口。像传送带状态这种一读就会真开 RS485 串口的调用,被明确排除在高频轮询之外——监控本身不该去干扰生产总线。

手机就是车间里的第二块屏

上料槽的状态变化几乎都发生在机器旁边,而不是办公桌前。操作工装完料要确认、发现卡料要标记异常、换单要改 SKU 和数量、调试时要单独试推一次料——这些动作如果都得跑回电脑前完成,系统就会被绕过去。

所以聚合中枢把这几件事做成了移动端优先的页面:手机连上车间局域网即可按机台操作,写操作经中枢转发回对应节点,和大屏读的是同一份快照。大屏只读免登录,手机端要登录。

手机就是车间里的第二块屏

07DESKTOP SHIP
从「跑起来」到「装得上」
Tauri 外壳 · Nuitka sidecar · Job Object · NSIS / .app

现场不会有人开终端敲命令。正式版把三样东西合成一个双击就能装的应用:Tauri 提供原生窗口与进程管理,Vue 前端由 Vite 编译后直接装进 WebView,FastAPI 经 Nuitka 编译成 standalone sidecar 随包发布——机台上不需要 Python 环境。

启动时 Tauri 先拉起 sidecar,确认本地端口就绪才显示主窗口;退出时同步关闭,Windows 侧用 Job Object 把后端绑定到应用生命周期,避免关掉窗口后还有一个进程在偷偷驱动传送带。正式版同时关闭接口文档、不注册调试路由。

新机台上线流程
01在中枢点「注册新节点」,拿到一条初始化命令
02新机台执行该命令,写入身份与主系统地址
03运行启动脚本,后端与控制台一起起来
04网页里选打印机、串口和生产参数
05中枢只填机台地址,名称自动读取
08EXTENDING
尚在扩展的节点类型
激光打标 · 扫码分拣 —— 交付时仍在开发,尚未全量上线

打包机之外的工序按同样的「节点」模型接入。其中激光打标带来一个有意思的反转:厂商系统是拉取式的——设备视觉识别出产品后主动连过来问我们要内容,而不是我们把内容推给它。于是控制、内容、模板三件事各走一条 TCP 链路,驱动层做成可插拔,能在厂商系统、直连打标板卡和纯仿真之间切换而上层零改动。

激光打标节点
› 拉取式协议:设备主动索取内容
› 控制 / 内容 / 模板三条 TCP 链路
› 驱动可插拔:厂商系统 · 直连板卡 · 仿真
› 队列、模板绑定与界面跨平台可调试
扫码分拣节点
› TCP 收码 + FTP 收抓拍图
› 留存设备时间、服务端时间与原始字段
› 每次有效扫码生成一条分拣任务
› 领取 → 完成确认 / 失败重试
PULL-BASED LASER LINK
打标节点 队列 · 模板绑定 厂商系统 + 设备 视觉识别产品 控制:启停 / 换型 / 查状态 内容:设备来问「打什么」 模板:设备来问「用哪张」 驱动层可插拔:厂商系统 · 直连打标板卡 · 纯仿真
和其它设备相反:镭雕机的视觉认出产品后主动连过来要内容,我们是被问的那一方。控制、内容、模板各走一条链路。

我在这个项目里学到的

物理世界不接受重试

Web 里一次失败请求刷新就好,产线上一次错误的输出点脉冲是一件报废的货。设计的重心从「怎么恢复」前移到「怎么让非法状态压根无法表达」。

现场设备不守协议

文档写着支持 HTTP,实际报文不合规范。真正能落地的做法不是要求设备改,而是在自己这一侧多留一层兼容和一份原始信号留档。

交付形态也是功能

能跑通不等于能交付。一条命令上线新机台、双击安装、关窗口不留残留进程,这些决定了系统是被用起来还是被绕过去。

3
层级架构
10
产线状态
4
设备 Actor
2
生产模式
SSE
大屏推流
断网
仍可续产

本页为脱敏介绍:不包含源码、仓库地址、真实客户与厂商名称、设备地址、凭据与授权机制细节。