ilesp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ilesp/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """ILESP — Intelligent Logistics Equipment Scheduling Platform.
2
+
3
+ 核心库(纯 stdlib,零框架依赖):
4
+ from ilesp.core.tasks import TaskStore, TaskStatus
5
+ from ilesp.wcs.scheduler import Topology, WcsScheduler
6
+ from ilesp.rcs.traffic import TrafficController
7
+ from ilesp.runtime import IlespRuntime
8
+
9
+ 可选 FastAPI 服务:
10
+ pip install ilesp[api] → from ilesp.api import create_app
11
+ """
12
+ from ilesp.core.tasks import TaskStore, TaskStatus
13
+ from ilesp.wcs.scheduler import Topology, WcsScheduler, Station
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = ["TaskStore", "TaskStatus", "Topology", "WcsScheduler", "Station", "__version__"]
@@ -0,0 +1 @@
1
+ """ILESP."""
ilesp/adapters/base.py ADDED
@@ -0,0 +1,46 @@
1
+ """ILESP 适配器缝:模拟与真实设备的统一接口(平滑过渡的唯一机制)。
2
+
3
+ EquipmentAdapter —— 设备级(输送线站台/提升机/门):WCS 逐站推进的执行面
4
+ FleetAdapter —— 车队级(AMR):RCS 搬运腿的执行面
5
+
6
+ simulated/* 与 real/*(modbus 等)实现同一接口;生产配置引用 real/*,
7
+ 测试/演示引用 simulated/*——代码零分叉。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+
14
+ @runtime_checkable
15
+ class EquipmentAdapter(Protocol):
16
+ """设备级执行面。实现方负责站台状态机(有货/方向/故障/心跳)。"""
17
+
18
+ def load_topology(self) -> list[dict[str, Any]]:
19
+ """声明站台清单 [{station_no, kind, zone}](WCS 据此建拓扑)。"""
20
+ ...
21
+
22
+ def move_cargo(self, from_station: str, to_station: str, cargo: dict[str, Any]) -> bool:
23
+ """请求设备把货物从 from 推进到 to(相邻站)。阻塞直到完成或失败;False=设备拒绝/故障。"""
24
+ ...
25
+
26
+ def station_state(self, station_no: str) -> dict[str, Any]:
27
+ """站台当前状态 {occupied, fault, auto, ...}(WCS 发车前问下游收不收得下)。"""
28
+ ...
29
+
30
+
31
+ @runtime_checkable
32
+ class FleetAdapter(Protocol):
33
+ """车队级执行面。实现方负责 AMR 行驶/电量/交通(仿真内核或真实 RCS/VDA5050)。"""
34
+
35
+ def available_amrs(self) -> list[str]:
36
+ """当前可接单的 AMR id 列表。"""
37
+ ...
38
+
39
+ def dispatch(self, amr_id: str, mission_id: str, from_station: str, to_station: str,
40
+ cargo: dict[str, Any]) -> bool:
41
+ """下发搬运腿。返回是否受理(受理后由实现方异步推进,pick_up/drop_off 回调经 runtime)。"""
42
+ ...
43
+
44
+ def mission_state(self, mission_id: str) -> dict[str, Any]:
45
+ """腿执行状态 {state: driving|arrived|failed, progress: 0..1}。"""
46
+ ...
@@ -0,0 +1,131 @@
1
+ """点表导入器(M3,PlcSimulator Import 设计借鉴):项目点表 CSV → ILESP 设备配置。
2
+
3
+ CSV 列(首行 header,UTF-8):
4
+ station_no,kind,zone,link_prev,link_next,status_offset,task_offset
5
+ - link_prev/link_next:显式链式相邻(避免方向值歧义——PlcSimulator 的教训:
6
+ "33 行 stationNo/itemname 不一致"与"arrow 方向值"都曾造成静默归组失败)
7
+ - status_offset/task_offset:该站台在 Modbus 保持寄存器中的状态字/任务字偏移
8
+ - kind:conveyor | lifter | door | scanner | dock(缺省 conveyor)
9
+
10
+ 产出设备配置 JSON(ModbusEquipmentAdapter 直接消费):
11
+ {"stations": [{station_no, kind, zone, status_offset, task_offset}],
12
+ "links": [[a, b], ...]}
13
+
14
+ 校验(导入即拦住数据问题,PlcSimulator 的"重复站号主动告警"同思路):
15
+ - 重复 station_no → 错误
16
+ - 偏移冲突/越界 → 错误
17
+ - link_prev/link_next 断链(单向声明)→ 告警并自动补全反向
18
+ - 孤儿站台(无任何链接)→ 告警(合法:dock/amr_dock 端点站)
19
+ - offset 与 station_no 数量不一致 → 告警
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import csv
24
+ import json
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ KINDS = ("conveyor", "lifter", "door", "scanner", "dock", "amr_dock")
29
+
30
+
31
+ def import_points_csv(path: str | Path) -> dict[str, Any]:
32
+ """导入点表 CSV → 设备配置 dict(含 warnings)。"""
33
+ rows: list[dict[str, str]] = []
34
+ with open(path, encoding="utf-8-sig", newline="") as f:
35
+ reader = csv.DictReader(f)
36
+ for col in ("station_no",):
37
+ if col not in (reader.fieldnames or []):
38
+ raise ValueError(f"点表缺必需列 {col}(header: {reader.fieldnames})")
39
+ rows = list(reader)
40
+
41
+ errors: list[str] = []
42
+ warnings: list[str] = []
43
+ stations: list[dict[str, Any]] = []
44
+ links: set[tuple[str, str]] = set()
45
+ seen: set[str] = set()
46
+ seen_offsets: dict[int, str] = {}
47
+
48
+ for i, r in enumerate(rows):
49
+ no = (r.get("station_no") or "").strip()
50
+ tag = f"row{i + 2}" # CSV 行号(含 header)
51
+ if not no:
52
+ errors.append(f"{tag}: station_no 为空")
53
+ continue
54
+ if no in seen:
55
+ errors.append(f"{tag}: 重复 station_no {no}")
56
+ continue
57
+ seen.add(no)
58
+ kind = (r.get("kind") or "conveyor").strip()
59
+ if kind not in KINDS:
60
+ warnings.append(f"{tag}: 未知 kind '{kind}' → conveyor")
61
+ kind = "conveyor"
62
+ zone = (r.get("zone") or "Z1").strip()
63
+
64
+ def _int(col: str) -> int | None:
65
+ v = (r.get(col) or "").strip()
66
+ if v == "":
67
+ return None
68
+ try:
69
+ return int(v)
70
+ except ValueError:
71
+ errors.append(f"{tag}: {col} 非整数 '{v}'")
72
+ return None
73
+
74
+ so, to = _int("status_offset"), _int("task_offset")
75
+ for name, off in (("status_offset", so), ("task_offset", to)):
76
+ if off is not None:
77
+ if off in seen_offsets and seen_offsets[off] != no:
78
+ errors.append(f"{tag}: {name}={off} 与 {seen_offsets[off]} 冲突")
79
+ else:
80
+ seen_offsets[off] = no
81
+ stations.append({"station_no": no, "kind": kind, "zone": zone,
82
+ "status_offset": so, "task_offset": to})
83
+
84
+ # 链式相邻:显式 prev/next + 断链自动补全(告警)
85
+ by_no = {s["station_no"]: s for s in stations}
86
+ rows_by_no = {r.get("station_no", "").strip(): r for r in rows}
87
+ for s in stations:
88
+ no = s["station_no"]
89
+ for col, peer_col in (("link_next", "link_prev"), ("link_prev", "link_next")):
90
+ peer = (rows_by_no.get(no, {}).get(col) or "").strip()
91
+ if not peer:
92
+ continue
93
+ if peer not in by_no:
94
+ errors.append(f"{no}: {col}={peer} 不存在")
95
+ continue
96
+ links.add((no, peer) if col == "link_next" else (peer, no))
97
+ back = (rows_by_no.get(peer, {}).get(peer_col) or "").strip()
98
+ if back != no:
99
+ warnings.append(f"{no}: 与 {peer} 单向链接({peer} 未声明 {peer_col})→ 自动补全")
100
+ links.add((peer, no) if col == "link_next" else (no, peer))
101
+
102
+ # 孤儿站台
103
+ linked = {a for a, b in links} | {b for a, b in links}
104
+ for s in stations:
105
+ if s["station_no"] not in linked:
106
+ warnings.append(f"{s['station_no']}: 孤儿站台(无链接;dock/amr_dock 端点为合法)")
107
+
108
+ return {"stations": stations, "links": [list(l) for l in sorted(links)],
109
+ "errors": errors, "warnings": warnings}
110
+
111
+
112
+ def check_report(config: dict[str, Any]) -> str:
113
+ """`check` 命令的人类可读报告:每站台"字节 ↔ 寄存器"映射 + 链接表(PlcSimulator check 同思路)。"""
114
+ lines = ["站台映射(HR 偏移):"]
115
+ for s in config["stations"]:
116
+ lines.append(f" {s['station_no']:<10} {s['kind']:<9} "
117
+ f"状态字 HR{40001 + (s['status_offset'] or 0)} 任务字 HR{40001 + (s['task_offset'] or 0)}")
118
+ lines.append("链接:")
119
+ for a, b in config["links"]:
120
+ lines.append(f" {a} ⇄ {b}")
121
+ for w in config.get("warnings", []):
122
+ lines.append(f"⚠ {w}")
123
+ for e in config.get("errors", []):
124
+ lines.append(f"✗ {e}")
125
+ return "\n".join(lines)
126
+
127
+
128
+ def import_to_file(csv_path: str | Path, out_path: str | Path) -> dict[str, Any]:
129
+ config = import_points_csv(csv_path)
130
+ Path(out_path).write_text(json.dumps(config, ensure_ascii=False, indent=1), encoding="utf-8")
131
+ return config
@@ -0,0 +1,116 @@
1
+ """Modbus 设备适配器(M3 硬化版):配置驱动 + 位译码 + 握手完成语义 + 断线重连。
2
+
3
+ - 配置来自点表导入器(importer.py):每站台 {status_offset, task_offset};
4
+ - 状态字位定义(对齐现场惯例,PlcSimulator 逆向语义):X8=有货(occupied) X9=自动(auto) X10=故障(fault);
5
+ - 握手完成语义:WCS 写任务字(task_no) → PLC 执行 → **任务字清零 = 本腿到达确认(landed)**——
6
+ runtime 的到达确认(landed)由此驱动,与真实 PLC 握手感一致;
7
+ - 断线重连:读写异常 → 置脏 → 下次调用重建连接(单次重试)。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Any
13
+
14
+ BIT_OCCUPIED = 0x0100 # X8
15
+ BIT_AUTO = 0x0200 # X9
16
+ BIT_FAULT = 0x0400 # X10
17
+
18
+
19
+ class ModbusEquipmentAdapter:
20
+ def __init__(self, host: str, port: int = 502, device_config: dict[str, Any] | None = None,
21
+ client_factory: Any = None, timeout_s: float = 2.0,
22
+ task_clear_grace_ticks: int = 0) -> None:
23
+ cfg = device_config or {"stations": [], "links": []}
24
+ self.host, self.port = host, port
25
+ self.stations: dict[str, dict[str, Any]] = {
26
+ s["station_no"]: s for s in cfg["stations"]}
27
+ self.links = {tuple(l) for l in cfg.get("links", [])}
28
+ self.timeout_s = timeout_s
29
+ self._factory = client_factory or self._default_factory
30
+ self._cli: Any = None
31
+ self._dirty = False
32
+ self._pending_task: dict[str, int] = {} # to_station → task_no(握手未完成)
33
+
34
+ @staticmethod
35
+ def _default_factory(host: str, port: int) -> Any:
36
+ from pymodbus.client import ModbusTcpClient
37
+ return ModbusTcpClient(host, port=port)
38
+
39
+ # ── 连接管理(断线置脏 + 单次重连) ──
40
+ def _client(self) -> Any:
41
+ if self._cli is None or self._dirty:
42
+ self._cli = self._factory(self.host, self.port)
43
+ if not self._cli.connect():
44
+ raise ConnectionError(f"Modbus 连接失败 {self.host}:{self.port}")
45
+ self._dirty = False
46
+ return self._cli
47
+
48
+ def _read_hr(self, offset: int) -> int:
49
+ try:
50
+ rr = self._client().read_holding_registers(offset, 1)
51
+ except Exception:
52
+ self._dirty = True
53
+ rr = self._client().read_holding_registers(offset, 1) # 单次重连重试
54
+ if rr.isError():
55
+ raise RuntimeError(f"读 HR{40001 + offset} 错误:{rr}")
56
+ return rr.registers[0]
57
+
58
+ def _write_hr(self, offset: int, value: int) -> None:
59
+ try:
60
+ rr = self._client().write_register(offset, value)
61
+ except Exception:
62
+ self._dirty = True
63
+ rr = self._client().write_register(offset, value)
64
+ if rr.isError():
65
+ raise RuntimeError(f"写 HR{40001 + offset} 错误:{rr}")
66
+
67
+ # ── EquipmentAdapter ──
68
+ def load_topology(self) -> list[dict[str, Any]]:
69
+ return [{"station_no": no, "kind": s["kind"], "zone": s["zone"]}
70
+ for no, s in self.stations.items()]
71
+
72
+ def station_state(self, station_no: str) -> dict[str, Any]:
73
+ s = self.stations[station_no]
74
+ if s.get("status_offset") is None:
75
+ return {"occupied": False, "landed": False, "fault": False, "auto": True}
76
+ word = self._read_hr(s["status_offset"])
77
+ occupied = bool(word & BIT_OCCUPIED)
78
+ task_off = s.get("task_offset")
79
+ handshake_done = True
80
+ if task_off is not None and station_no in self._pending_task:
81
+ task_word = self._read_hr(task_off)
82
+ handshake_done = task_word == 0 # PLC 清任务字 = 本腿执行完成
83
+ if handshake_done:
84
+ del self._pending_task[station_no]
85
+ return {"occupied": occupied,
86
+ "landed": occupied and handshake_done and station_no not in self._pending_task,
87
+ "fault": bool(word & BIT_FAULT),
88
+ "auto": bool(word & BIT_AUTO)}
89
+
90
+ def move_cargo(self, from_station: str, to_station: str, cargo: dict[str, Any]) -> bool:
91
+ """写目的站任务字(任务号)= 受理;到达由 runtime 的 landed 确认轮询判定。"""
92
+ s = self.stations.get(to_station)
93
+ if s is None or s.get("task_offset") is None:
94
+ return False
95
+ st = self.station_state(to_station)
96
+ if st["fault"] or not st["auto"] or st["occupied"]:
97
+ return False # 下游忙/故障/手动 → 不发车
98
+ task_no = int(cargo.get("task_no", int(time.time()) % 30000))
99
+ self._write_hr(s["task_offset"], task_no)
100
+ self._pending_task[to_station] = task_no
101
+ return True
102
+
103
+ def load_cargo(self, station_no: str, cargo: dict[str, Any]) -> bool:
104
+ """真实设备:货物上载是物理现实(X8 位由 PLC 置位)——适配器侧无需动作。"""
105
+ return True
106
+
107
+ def cargo_at(self, station_no: str) -> dict[str, Any] | None:
108
+ """真实设备不保存随货 JSON——返回占位(payload 由任务账本供)。"""
109
+ return {"station_no": station_no} if self.station_state(station_no).get("occupied") else None
110
+
111
+ def inject_fault(self, station_no: str, on: bool) -> None:
112
+ """调试辅助:写故障位(X10)。"""
113
+ s = self.stations[station_no]
114
+ word = self._read_hr(s["status_offset"])
115
+ word = (word | BIT_FAULT) if on else (word & ~BIT_FAULT)
116
+ self._write_hr(s["status_offset"], word)
@@ -0,0 +1 @@
1
+ """ILESP."""
@@ -0,0 +1,120 @@
1
+ """内置仿真适配器:模拟输送站台 + 模拟 AMR 车队(确定性:固定 tick + 可传 seed)。"""
2
+ from __future__ import annotations
3
+
4
+ import random
5
+ from typing import Any
6
+
7
+
8
+ class SimulatedEquipment:
9
+ """模拟输送线:站台占用/故障状态 + 相邻推进(50ms 级 tick 由 runtime 驱动)。"""
10
+
11
+ def __init__(self, stations: list[dict[str, Any]], seed: int | None = 42,
12
+ move_ticks: int = 4, fault_rate: float = 0.0) -> None:
13
+ self.kind_of = {s["station_no"]: s.get("kind", "conveyor") for s in stations}
14
+ self.occupied: dict[str, dict[str, Any] | None] = {s["station_no"]: None for s in stations}
15
+ self.fault: dict[str, bool] = {s["station_no"]: False for s in stations}
16
+ self.auto: dict[str, bool] = {s["station_no"]: True for s in stations}
17
+ self._moving: dict[str, dict[str, Any]] = {} # to_station → cargo(在途)
18
+ self._move_left = {s["station_no"]: 0 for s in stations}
19
+ self._move_ticks = max(1, move_ticks)
20
+ self._rng = random.Random(seed)
21
+ self.fault_rate = fault_rate
22
+
23
+ # ── EquipmentAdapter ──
24
+ def load_topology(self) -> list[dict[str, Any]]:
25
+ return [{"station_no": no, "kind": k, "zone": "Z1"} for no, k in self.kind_of.items()]
26
+
27
+ def move_cargo(self, from_station: str, to_station: str, cargo: dict[str, Any]) -> bool:
28
+ """把货从 from 推进到 to:源清空、目的进入在途;目的忙/故障/非自动 → False(WCS 等待)。"""
29
+ if self.fault.get(to_station) or not self.auto.get(to_station, True):
30
+ return False
31
+ if self.occupied.get(to_station) is not None or to_station in self._moving:
32
+ return False # 下游收不收得下(WaitingDownstream 语义)
33
+ if self.occupied.get(from_station) is None:
34
+ return False
35
+ self.occupied[from_station] = None
36
+ self._moving[to_station] = dict(cargo)
37
+ self._move_left[to_station] = self._move_ticks
38
+ return True
39
+
40
+ def cargo_at(self, station_no: str) -> dict[str, Any] | None:
41
+ """站上当前货物(含随货信息 task_id 等)。"""
42
+ return self.occupied.get(station_no)
43
+
44
+ def load_cargo(self, station_no: str, cargo: dict[str, Any]) -> bool:
45
+ """上货点装载(WMS 入库语义):站空闲则上货。"""
46
+ if self.occupied.get(station_no) is not None or station_no in self._moving:
47
+ return False
48
+ self.occupied[station_no] = dict(cargo)
49
+ return True
50
+
51
+ def station_state(self, station_no: str) -> dict[str, Any]:
52
+ """occupied = 忙(在途也算,供"下一站忙不发车");landed = 货已落位(供到达确认)。"""
53
+ return {"occupied": self.occupied.get(station_no) is not None or station_no in self._moving,
54
+ "landed": self.occupied.get(station_no) is not None,
55
+ "fault": self.fault.get(station_no, False),
56
+ "auto": self.auto.get(station_no, True)}
57
+
58
+ # ── 仿真推进(runtime tick 调用) ──
59
+ def tick(self) -> None:
60
+ """在途货物计时推进;到站落位。偶发故障(可配 fault_rate,seed 确定)。"""
61
+ done = [no for no, left in self._move_left.items() if left > 0]
62
+ for no in done:
63
+ self._move_left[no] -= 1
64
+ if self._move_left[no] <= 0:
65
+ self.occupied[no] = self._moving.pop(no)
66
+ if self.fault_rate > 0 and self._rng.random() < self.fault_rate:
67
+ self.fault[no] = True
68
+
69
+ def inject_fault(self, station_no: str, on: bool = True) -> None:
70
+ self.fault[station_no] = on
71
+
72
+
73
+ class SimulatedFleet:
74
+ """模拟 AMR 车队:直线行驶 + 电量 + 充电回归(运动学点模型,借鉴 fleetdeck 的保真度取舍)。"""
75
+
76
+ def __init__(self, amr_ids: list[str], seed: int | None = 42,
77
+ speed_mps: float = 1.2, distance_m: float = 24.0, tick_s: float = 0.2) -> None:
78
+ self.battery = {a: 60.0 + random.Random(seed).random() * 30 for a in amr_ids}
79
+ self.missions: dict[str, dict[str, Any]] = {} # mission_id → 腿执行状态
80
+ self.amr_mission: dict[str, str | None] = {a: None for a in amr_ids}
81
+ self.progress: dict[str, float] = {} # mission_id → 0..1
82
+ self._rng = random.Random(seed)
83
+ self.speed = speed_mps
84
+ self.dist = distance_m
85
+ self.tick_s = tick_s
86
+ self._ids = list(amr_ids)
87
+
88
+ # ── FleetAdapter ──
89
+ def available_amrs(self) -> list[str]:
90
+ return [a for a in self._ids if self.amr_mission.get(a) is None and self.battery[a] > 20]
91
+
92
+ def dispatch(self, amr_id: str, mission_id: str, from_station: str, to_station: str,
93
+ cargo: dict[str, Any]) -> bool:
94
+ if self.amr_mission.get(amr_id) is not None:
95
+ return False
96
+ self.amr_mission[amr_id] = mission_id
97
+ self.missions[mission_id] = {"amr": amr_id, "from": from_station, "to": to_station,
98
+ "state": "driving", "cargo": dict(cargo)}
99
+ self.progress[mission_id] = 0.0
100
+ return True
101
+
102
+ def mission_state(self, mission_id: str) -> dict[str, Any]:
103
+ m = self.missions.get(mission_id)
104
+ if m is None:
105
+ return {"state": "unknown", "progress": 0.0}
106
+ return {"state": m["state"], "progress": round(self.progress.get(mission_id, 0.0), 3)}
107
+
108
+ # ── 仿真推进 ──
109
+ def tick(self) -> None:
110
+ """每 tick 行驶一段;电量消耗;到站置 arrived。"""
111
+ for mid, m in list(self.missions.items()):
112
+ if m["state"] != "driving":
113
+ continue
114
+ p = self.progress[mid] + (self.speed * self.tick_s) / self.dist
115
+ self.battery[m["amr"]] = max(0.0, self.battery[m["amr"]] - 0.05 * self.speed * self.tick_s)
116
+ if p >= 1.0:
117
+ self.progress[mid] = 1.0
118
+ m["state"] = "arrived"
119
+ else:
120
+ self.progress[mid] = p
@@ -0,0 +1,146 @@
1
+ """VDA5050 FleetAdapter:MQTT 传输 + 语义三判据 + 预留 TTL——FleetAdapter 协议的多厂商 AMR 实现。
2
+
3
+ 传输可插拔:`mqtt_transport` 参数接受 (publish, subscribe) 二元组;
4
+ 测试/演示可注入 InProcessMqtt(内存总线);生产用 paho-mqtt 连 mosquitto/EMQX。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time
10
+ from typing import Any, Callable
11
+
12
+ from .base import FleetAdapter
13
+ from ..rcs.vda5050 import VdaOrder, VdaState, build_order, topic_order, topic_state
14
+
15
+
16
+ class InProcessMqtt:
17
+ """进程内 MQTT 总线(测试/演示:同一进程内 publish → subscribe 回调)。"""
18
+
19
+ def __init__(self) -> None:
20
+ self.subs: dict[str, list[Callable[[str, bytes], None]]] = {}
21
+ self.messages: list[tuple[str, bytes]] = []
22
+
23
+ def publish(self, topic: str, payload: bytes, qos: int = 0) -> None:
24
+ self.messages.append((topic, payload))
25
+ for pat, cbs in self.subs.items():
26
+ if self._match(pat, topic):
27
+ for cb in cbs:
28
+ cb(topic, payload)
29
+
30
+ def subscribe(self, topic: str, callback: Callable[[str, bytes], None]) -> None:
31
+ self.subs.setdefault(topic, []).append(callback)
32
+
33
+ @staticmethod
34
+ def _match(pattern: str, topic: str) -> bool:
35
+ """MQTT 通配符 + / # 匹配。"""
36
+ pp, tp = pattern.split("/"), topic.split("/")
37
+ for i, seg in enumerate(pp):
38
+ if seg == "#":
39
+ return True
40
+ if i >= len(tp):
41
+ return False
42
+ if seg != "+" and seg != tp[i]:
43
+ return False
44
+ return len(pp) == len(tp)
45
+
46
+
47
+ class Vda5050FleetAdapter(FleetAdapter):
48
+ """FleetAdapter 的 VDA5050 over MQTT 实现(多厂商 AMR 统一接入)。
49
+
50
+ transport = (publish_fn, subscribe_fn)——可插拔:测试注入 InProcessMqtt,
51
+ 生产注入 paho-mqtt 客户端的 publish/subscribe 封装。
52
+ """
53
+
54
+ OFFLINE_S = 15.0 # 无 state 心跳 → 离线
55
+ EVICT_S = 300.0 # 持续离线 → 驱逐
56
+
57
+ def __init__(self, manufacturer: str = "ilesp", transport: Any = None,
58
+ battery_min: float = 20.0) -> None:
59
+ self.mfr = manufacturer
60
+ self.battery_min = battery_min
61
+ self.robots: dict[str, dict[str, Any]] = {} # serial → {state: VdaState, last_seen, order_id}
62
+ self.reserved: dict[str, tuple[str, float]] = {} # serial → (task_id, at)
63
+ self._transport = transport
64
+ if transport is not None and hasattr(transport, "publish"):
65
+ self._publish = transport.publish
66
+ self._subscribe = transport.subscribe
67
+ transport.subscribe(topic_state(self.mfr, "+"), self._on_state)
68
+ else:
69
+ self._publish = transport[0] if isinstance(transport, tuple) else None
70
+ self._subscribe = transport[1] if isinstance(transport, tuple) and len(transport) > 1 else None
71
+ if self._subscribe:
72
+ self._subscribe(topic_state(self.mfr, "+"), self._on_state)
73
+
74
+ # ── MQTT 回调 ──
75
+ def _on_state(self, topic: str, payload: bytes) -> None:
76
+ try:
77
+ d = json.loads(payload)
78
+ except Exception:
79
+ return
80
+ serial = d.get("serialNumber", "")
81
+ if not serial:
82
+ return
83
+ st = VdaState.from_json(d)
84
+ self.robots[serial] = {"state": st, "last_seen": time.time()}
85
+ # fleetdeck:机器人报到对应 orderId 时释放预留
86
+ oid = st.order_id
87
+ if oid:
88
+ for r, (tid, _) in list(self.reserved.items()):
89
+ if r == serial and tid == oid:
90
+ del self.reserved[r]
91
+
92
+ def publish_order(self, serial: str, order: VdaOrder) -> None:
93
+ """向指定机器人发布 order(QoS1 语义——预留在 adapter 层)。"""
94
+ topic = topic_order(self.mfr, serial)
95
+ payload = json.dumps(order.to_json()).encode()
96
+ if self._publish is not None:
97
+ self._publish(topic, payload, qos=1)
98
+ # 本地也登记(模拟车直接消费;真车经 MQTT 收到后回 state 报到)
99
+ self.robots.setdefault(serial, {"state": VdaState(manufacturer=self.mfr, serial_number=serial),
100
+ "last_seen": time.time()})
101
+ self.robots[serial]["order_id"] = order.order_id
102
+
103
+ # ── FleetAdapter 协议 ──
104
+ def available_amrs(self) -> list[str]:
105
+ now = time.time()
106
+ out = []
107
+ for serial, info in self.robots.items():
108
+ if now - info["last_seen"] > self.OFFLINE_S:
109
+ continue
110
+ if serial in self.reserved:
111
+ continue
112
+ st: VdaState = info["state"]
113
+ if st.is_available() and st.battery_charge > self.battery_min:
114
+ out.append(serial)
115
+ return out
116
+
117
+ def dispatch(self, amr_id: str, mission_id: str, from_station: str,
118
+ to_station: str, cargo: dict[str, Any]) -> bool:
119
+ if amr_id in self.reserved:
120
+ return False
121
+ order = build_order(mission_id, [from_station, to_station])
122
+ self.publish_order(amr_id, order)
123
+ self.reserved[amr_id] = (mission_id, time.time())
124
+ return True
125
+
126
+ def mission_state(self, mission_id: str) -> dict[str, Any]:
127
+ for serial, info in self.robots.items():
128
+ st: VdaState = info["state"]
129
+ if st.order_id == mission_id:
130
+ if st.has_finished():
131
+ return {"state": "arrived", "progress": 1.0}
132
+ if st.is_executing():
133
+ prog = 1.0 - min(1.0, st.remaining_nodes() / 2.0) if st.remaining_nodes() else 0.5
134
+ return {"state": "driving", "progress": round(prog, 2)}
135
+ return {"state": "unknown", "progress": 0.0}
136
+
137
+ def battery(self, serial: str) -> float:
138
+ info = self.robots.get(serial)
139
+ return info["state"].battery_charge if info else 0.0
140
+
141
+ # ── 预留 TTL(fleetdeck 模式) ──
142
+ def _reclaim_expired(self) -> None:
143
+ now = time.time()
144
+ expired = [(s, tid) for s, (tid, at) in self.reserved.items() if now - at > 60.0]
145
+ for s, tid in expired:
146
+ del self.reserved[s]