# Machine Actor: 设备状态机 goroutine 化 > **目标:** 每台设备一个 goroutine,自治管理槽位状态和 PLC 信号监听,替换当前散落在 EventLoop/Station/SignalWatcher 中的设备逻辑。 **架构:** Actor 模型 — goroutine + channel,私有状态,外部只能发消息/读快照。全局调度器聚合快照做跨设备决策,EventLoop 保持 DB 单线程写。 **实现方式:** 重写。不迁移旧 Station 代码,新 Actor 替换旧 Station 后删除旧代码。 --- ## 1. 动机 当前问题: 1. **设备状态更新散落两处** — `handleMachineDone` 先写 DB equipment_slot,再调 `updateStationDone` 写 Station 内存,两个操作不在同一事务 2. **调度器读两个源** — `buildSystemState` 从 Station 内存读 `MachineBusy`,从 DB 读 `MachineHasJob`,语义重叠但不同步 3. **handleMachineDone 职责过重** — 批量/单信号设备分支、slot 查找、DB 更新、Station 更新、Job 推进混在一个方法 4. **SignalWatcher 集中轮询** — 所有设备走一个 ticker,设备数量增加时轮询粒度不可控 核心矛盾:设备状态(槽位 Empty/Occupied/Done + 工站 Idle/Processing/Full/Waiting)天然属于设备自身,但当前被 EventLoop 和 Station 分而治之。 --- ## 2. 架构概览 ``` PLC 硬件信号 ↓ SignalRouter 集中轮询,路由到各 Actor channel ┌──────────────────────────────────────────────────────────────┐ │ MachineActor-1 MachineActor-2 TempStoreActor │ │ (CNC #1, goroutine) (清洗机, goroutine) (暂存台, goroutine) │ │ │ │ 私有: slots[],status 私有: slots[],status 私有: slots[] │ │ 外部只能: Send/Snapshot │ │ │ │ 自写 DB: equipment_slot 自写 DB: job │ │ 发布事件: EvtMachineDone 无 PLC 信号 │ └──────┬───────────────────────────────────────────────────────┘ │ 发布事件到 EventBus ▼ GlobalScheduler ──── 聚合所有 Actor 快照 │ 换料配对、优先级排序 │ 输出 CandidateTask ▼ EventLoop ────────── Job 步骤推进 (DB) │ AdvanceStep / FinishJob │ 不碰 equipment_slot ▼ PostgreSQL ``` **职责边界:** | 组件 | 职责 | 不负责 | |------|------|--------| | MachineActor | 槽位状态机、PLC 信号监听、完成判定、DB equipment_slot 写入 | Job 步骤推进 | | TempStoreActor | 暂存台槽位分配/释放、DB job.temp_slot_no 写入 | Job 步骤推进 | | GlobalScheduler | 跨设备决策(换料配对、优先级) | 设备内部状态 | | EventLoop | Job 步骤推进、DB job 表写入 | 设备/暂存台状态管理 | --- ## 3. MachineActor 设计 ### 3.1 接口 ```go // MachineActor 设备状态机 type MachineActor interface { ID() int // 设备 ID Type() string // 设备类型编码 Run(ctx context.Context) // 启动 goroutine,阻塞 Send(msg MachineMsg) // 外部发消息(非阻塞,chan 容量 8) Snapshot() MachineSnapshot // 读快照(atomic.Value,无锁) } // MachineMsg 外部发给 Actor 的命令 type MachineMsg struct { Type string // LOAD_COMPLETE / UNLOAD_COMPLETE / RELEASE / RESTORE JobID int SlotNo int Params map[string]any } // MachineSnapshot 只读快照,供调度器聚合 type MachineSnapshot struct { ID int Type string Status ActorStatus // Idle/Processing/Full/Waiting/Fault Slots []SlotSnapshot UpdatedAt time.Time } type SlotSnapshot struct { SlotNo int Status SlotStatus // Empty/Occupied/Done JobID int OccupiedAt time.Time } ``` ### 3.2 工站状态机 ``` ┌──────────────────────────┐ │ FAULT │←── PLC 通信异常 / 硬件故障 │ (需人工介入恢复) │ └──────────────────────────┘ ↑ 异常 │ ┌──────────┐ Load完成 ┌──────────────┐ 满 ┌──────────┐ │ IDLE │──────────→ │ PROCESSING │───→ │ FULL │ │ (全Empty)│←────────── │ (有空+有占) │←─── │ (全占满) │ └──────────┘ Unload/全部 └──────────────┘ └──────────┘ 槽位释放 │ ↑ ↑ │ │ │ │ │ │ │ └─ Load(有空位) │ │ │ │ PLC 完成信号 │ └── PLC Done ────────┘ │ │ PLC 完成信号(非批量设备) ▼ ┌──────────────┐ │ WAITING │ ← 有 Done 槽位,等待卸料/换料 │ (有Done槽位) │ └──────────────┘ ``` 状态规则: - **IDLE**: 所有槽位 Empty - **PROCESSING**: 有空 + 有占。批量设备单信号完成时短暂停留 - **FULL**: 全部 Occupied 或 Occupied+Done 之和=capacity,不接受上料 - **WAITING**: 有至少一个 Done 槽位,调度器优先处理 - **FAULT**: PLC 通信异常,需人工恢复 ### 3.3 槽位状态机 ``` Empty ──Load完成──→ Occupied ──PLC Done──→ Done ──Unload完成──→ Empty ``` ### 3.4 批量设备 vs 单信号设备 两类设备完成行为不同,在 Actor 内部通过 `batch` 配置区分: ```go type machineActor struct { id int batch bool // true: 批量完成(所有 Occupied→Done) // ... } ``` **批量设备**(`batch=true`,如高压清洗机):收到 PLC 完成信号 → 所有 Occupied 槽位标记 Done **单信号设备**(`batch=false`,如 CNC):收到 PLC 完成信号 → 按 OccupiedAt FIFO 找最早占用槽位标记 Done ### 3.5 检测设备 检测设备(内窥镜检测线、抽检台)在 Actor 中维护 `hasNG` bool: - `handleMachineWaitCandidate` 的跳过行为保留在 EventLoop,不迁移到 Actor - 检测结果判定逻辑在 Actor 中: - Done 信号(pass)→ 发布 `EvtInspectionResult(machineID, true)` - NG 信号 → 发布 `EvtInspectionResult(machineID, false)` - EventLoop 消费 `EvtInspectionResult`:pass→CompleteStep→SetWaitingUnload;fail→FinishJob(Scrapped)+Alarm ### 3.6 Run 主循环 ```go func (m *machineActor) Run(ctx context.Context) { ticker := time.NewTicker(m.pollInterval) defer ticker.Stop() for { select { case msg := <-m.msgCh: m.handleMessage(msg) case evt := <-m.signalCh: m.handleSignal(evt) // 非阻塞接收 SignalRouter 推送 case <-ctx.Done(): return } } } ``` ### 3.7 消息处理 ```go func (m *machineActor) handleMessage(msg MachineMsg) { switch msg.Type { case "LOAD_COMPLETE": m.slots[msg.SlotNo-1] = SlotStatus_Occupied m.slotJobs[msg.SlotNo-1] = msg.JobID m.slotOccupiedAt[msg.SlotNo-1] = time.Now() m.updateStatus() m.db.SetEquipmentSlot(ctx, m.id, msg.SlotNo, SlotStatus_Empty, SlotStatus_Occupied, msg.JobID) case "UNLOAD_COMPLETE": m.db.SetEquipmentSlot(ctx, m.id, msg.SlotNo, SlotStatus_Done, SlotStatus_Empty, 0) m.slots[msg.SlotNo-1] = SlotStatus_Empty m.slotJobs[msg.SlotNo-1] = 0 m.slotOccupiedAt[msg.SlotNo-1] = time.Time{} m.updateStatus() case "RESTORE": m.slots[msg.SlotNo-1] = parseSlotStatus(msg.Params["status"]) m.slotJobs[msg.SlotNo-1] = msg.JobID m.updateStatus() } } ``` ### 3.8 信号处理 + DB 持久化 Actor 收到完成信号后:更新私有状态 → 写 DB → 发布事件通知 EventLoop 推进 Job。内存与 DB 在同一 goroutine 内顺序执行,无一致性问题。 检测设备的 Done 信号表示检测合格(pass),走 EvtInspectionResult 而非 EvtMachineDone。 ```go func (m *machineActor) handleSignal(evt SignalEvent) { // 检测设备 NG 信号 if evt.Type == SignalNG && evt.Value { m.bus.Publish(EventInspectionResult, m.id, false) return } if evt.Type == SignalDone && evt.Value { // 检测设备:Done 信号 = 检测合格 if m.inspection { // 标记所有 Occupied 槽位为 Done for i := range m.slots { if m.slots[i] == SlotStatus_Occupied { m.slots[i] = SlotStatus_Done m.db.SetEquipmentSlot(ctx, m.id, i+1, SlotStatus_Occupied, SlotStatus_Done, m.slotJobs[i]) } } m.updateStatus() // 发布 EvtInspectionResult(pass=true),EventLoop 处理 m.bus.Publish(EventInspectionResult, m.id, true) return } // 非检测设备:正常加工完成 var doneSlots []int if m.batch { for i := range m.slots { if m.slots[i] == SlotStatus_Occupied { m.slots[i] = SlotStatus_Done doneSlots = append(doneSlots, i+1) } } } else { slotNo := m.findFirstOccupied() if slotNo > 0 { m.slots[slotNo-1] = SlotStatus_Done doneSlots = append(doneSlots, slotNo) } } m.updateStatus() for _, slotNo := range doneSlots { m.db.SetEquipmentSlot(ctx, m.id, slotNo, SlotStatus_Occupied, SlotStatus_Done, m.slotJobs[slotNo-1]) } for _, slotNo := range doneSlots { m.bus.Publish(EventMachineDone, m.id, slotNo, m.slotJobs[slotNo-1]) } } } ``` --- ## 4. SignalRouter PLC 连接集中管理(S7 单连接),一个 goroutine 轮询,路由到各 Actor channel: ```go type SignalRouter struct { plc goplc.Client mu sync.RWMutex watches map[int]*SignalWatch } type SignalWatch struct { DoneAddr string // 完成信号 PLC 地址 NGAddr string // NG 信号地址(检测设备才有) Ch chan SignalEvent } type SignalEvent struct { Type SignalType // Done / NG Value bool } ``` - `Ch` 容量为 1,SignalRouter 写不阻塞 - Actor 从 `Ch` 非阻塞读取(`select default`),积压时丢弃旧值 --- ## 5. TempStoreActor(暂存台) 暂存台没有 PLC 信号,但需要管理槽位占用。与 MachineActor 的关键区别:**槽位从分配到释放贯穿 Job 整个生命周期**。 ``` 补料(分配slot3) → 上料到CNC(slot3仍属此job) → 下料回暂存台(slot3) → ... → 完成(释放slot3) ``` 中间 job 在机器上加工时,暂存台槽位是"虚占"状态 — 物理上工件不在,但槽位仍属此 job,不能被其他 job 使用。 ```go type TempStoreActor struct { capacity int slots []int // slotNo → jobID (0 = 空) msgCh chan MachineMsg db *DBState } func (t *TempStoreActor) Run(ctx context.Context) { for { select { case msg := <-t.msgCh: switch msg.Type { case "ALLOCATE": t.allocate(ctx, msg.JobID) case "RELEASE": t.release(ctx, msg.JobID) case "RESTORE": t.restore(ctx, msg.SlotNo, msg.JobID) } case <-ctx.Done(): return } } } func (t *TempStoreActor) allocate(ctx context.Context, jobID int) { for i := range t.slots { if t.slots[i] == 0 { t.slots[i] = jobID t.db.SetJobTempSlot(ctx, jobID, i+1) // 写 DB job.temp_slot_no return } } } func (t *TempStoreActor) release(ctx context.Context, jobID int) { for i := range t.slots { if t.slots[i] == jobID { t.slots[i] = 0 t.db.ClearJobTempSlot(ctx, jobID) // 清 DB job.temp_slot_no return } } } func (t *TempStoreActor) Snapshot() TempStoreSnapshot { return TempStoreSnapshot{ Capacity: t.capacity, Slots: append([]int(nil), t.slots...), } } ``` 调度器通过 `Snapshot` 读暂存台占用,替代 `buildSystemState` 中的 `TempSlotJobs` 计算和 `TempSlotAllocator`。 --- ## 6. 调度器集成 `buildSystemState` 从 Actor 单一源读(设备从 MachineActor,暂存台从 TempStoreActor): ```go func (l *ProductionEventLoop) buildSystemState(ctx context.Context) scheduler.SystemState { state := scheduler.SystemState{ MachineBusy: make(map[int]bool), MachineHasJob: make(map[int]int), TempSlotJobs: make(map[int]int), } for _, actor := range l.machineActors { snap := actor.Snapshot() state.MachineBusy[snap.ID] = snap.Status == ActorStatus_Full for _, slot := range snap.Slots { if slot.JobID > 0 && slot.Status != SlotStatus_Empty { state.MachineHasJob[snap.ID] = slot.JobID } } } // 暂存台 tempSnap := l.tempStoreActor.Snapshot() occupied := 0 for slotNo, jobID := range tempSnap.Slots { if jobID > 0 { state.TempSlotJobs[slotNo] = jobID occupied++ } } state.TempSlotFree = tempSnap.Capacity - occupied return state } ``` 换料配对逻辑不变,仍在 `trySchedule` 阶段。 --- ## 7. EventLoop 变更 **Job 状态持久化仍由 EventLoop 负责** — Job 是跨设备实体,不属于任何单一 Actor。 持久化职责分割: | 表 | Owner | 时机 | |---|---|---| | `equipment_slot` | MachineActor | 上料/下料/完成信号时 | | `job`(状态/步骤/位置/上下文) | EventLoop | AdvanceStep/FinishJob/SetJobProcessing | | `work_order` | EventLoop | FinishJob 时累加 | | `alarm` | EventLoop | Actor 发布故障事件时 | | `job.temp_slot_no` | TempStoreActor | ALLOCATE/RELEASE 时 | **新增:消费 Actor 事件,推进 Job** ```go case EventMachineDone: l.handleMachineDone(ctx, msg) // 精简后:只 AdvanceStep/SetWaitingUnload + trySchedule case EventInspectionResult: l.handleInspectionDone(ctx, machineID, pass) ``` **handleMachineDone 精简**:Actor 已完成 DB equipment_slot 写入,EventLoop 仅做 Job 步骤推进。不再调 `updateStationDone`,不再查 slot 状态。仅从事件中拿 machineID/slotNo/jobID 参数。 **Worker 完成后通知 Actor**:上料/下料成功后发送 `LOAD_COMPLETE`/`UNLOAD_COMPLETE` 消息,Actor 更新内存槽位状态并写 DB。 **删除**: - `updateStationDone` 方法 - `batchMachines` / `inspectionMachines` map — Actor 内部自管理 - equipment_slot 的 DB 写入逻辑 — 移至 Actor --- ## 8. 启动与恢复 ### 8.1 创建 ```go func BuildMachineActors(ctx context.Context, entClient *ent.Client, signalRouter *SignalRouter, bus eventbus.Bus) map[int]MachineActor { equipments, _ := entClient.Equipment.Query().WithEquipmentType().All(ctx) actors := make(map[int]MachineActor) for _, eq := range equipments { cfg := MachineActorConfig{ ID: eq.ID, Type: eq.Edges.EquipmentType.Code, Capacity: eq.SlotCount, Batch: eq.Batch, Inspection: eq.Edges.EquipmentType.Code == InspectionType, // 检测设备 PollInterval: 1 * time.Second, SignalCh: signalRouter.Watch(eq.ID, doneAddr, ngAddr), Bus: bus, } actors[eq.ID] = NewMachineActor(cfg) } return actors } ``` ### 8.2 恢复 `RecoverOnStartup` 从 DB `equipment_slot` 表恢复初始状态: ```go func (d *DBState) RecoverMachineActors(ctx context.Context, actors map[int]MachineActor) { slots, _ := d.client.EquipmentSlot.Query().All(ctx) for _, s := range slots { actor, ok := actors[*s.EquipmentId] if !ok { continue } actor.Send(MachineMsg{ Type: "RESTORE", JobID: slotJobID(s), SlotNo: s.SlotNo, Params: map[string]any{"status": string(s.Status)}, }) } } ``` RESTORE 直接设置槽位状态(跳过 Empty→Occupied 转换校验)。 --- ## 9. 删除清单 新 Actor 上线后删除: - `internal/station/` — 整个 Station 包(接口、BaseStation、CNC/Cleaning/Washer/Inspection/Deburr/Sampling/Scanner/LaserMarker/Registry),保留 `HandheldTool` 接口 - `internal/processor/temp_slot_allocator.go` — TempSlotAllocator(被 TempStoreActor 替代) - `internal/processor/signal_watcher.go` — SignalWatcher(被 SignalRouter 替代) - `internal/processor/step_timeout.go` — 工序超时 - `internal/eventloop/loop.go` — `updateStationDone`、`batchMachines`、`inspectionMachines` --- ## 10. 测试策略 - **MachineActor 单元测试**: 注入模拟 signalCh,验证 Idle→Processing→Full→Waiting 全路径 - **TempStoreActor 单元测试**: 验证 ALLOCATE/RELEASE 分配释放 - **批量设备测试**: 验证 PLC Done → 所有 Occupied → Done - **检测设备测试**: 验证 pass/NG 信号 → 正确事件发布 - **恢复测试**: RESTORE 消息恢复各状态槽位 - **集成测试**: Actor + SignalRouter + EventLoop 联调完整链路