chore: init bj_power monorepo (dashboard/mes/wms/wms_client/workstation), clear subproject git metadata and align naming to folder names
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
# 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 联调完整链路
|
||||
Reference in New Issue
Block a user