muse-driver-framework 1.0.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.
@@ -0,0 +1,1442 @@
1
+ """MUSE Python 程序可复用的异步 TCP 设备框架。
2
+
3
+ 这个文件提供通用连接生命周期和实时帧组件,不直接依赖 ``mojo.context``:
4
+
5
+ * ``AsyncTcpDeviceManager`` 创建一个后台线程和一个共享 asyncio Event Loop;
6
+ * 每台设备对应一个 ``AsyncTcpDeviceClient``,在共享 Event Loop 中运行;
7
+ * 每台设备有独立的监听、发送队列、状态轮询和断线重连任务;
8
+ * ``MeterStream`` 提供与传输无关的只读表头整帧缓存和限频发布;
9
+ * 具体协议如何登录、如何生成命令、如何解析反馈,由 ``index.py`` 中的子类实现。
10
+ """
11
+
12
+ # asyncio 提供协程、Event Loop、异步 TCP 连接和异步队列。
13
+ import asyncio
14
+
15
+ # json 用于在管理器基类中读取通用设备配置。
16
+ import json
17
+ import inspect
18
+ import math
19
+ import time
20
+ import uuid
21
+
22
+ # threading 用来把 asyncio Event Loop 放进独立后台线程,避免阻塞 MUSE 主线程。
23
+ import threading
24
+
25
+ # ABC 和 abstractmethod 用来声明必须由具体设备驱动实现的接口。
26
+ from abc import ABC, abstractmethod
27
+
28
+
29
+ CONTROL_TYPE_SCHEMA_VERSION = 1
30
+ CONTROL_TYPE_DEFINITIONS = {
31
+ "slider": {"family": "number", "delivery": "continuous"},
32
+ "level": {"family": "number", "delivery": "continuous"},
33
+ "volume": {"family": "number", "delivery": "continuous"},
34
+ # Meter is read-only realtime telemetry, not a writable numeric control.
35
+ "meter": {"family": "number", "delivery": "realtime", "access": "ro"},
36
+ "dimmer": {"family": "number", "delivery": "continuous"},
37
+ "position": {"family": "number", "delivery": "continuous"},
38
+ "toggle": {"family": "boolean", "delivery": "discrete"},
39
+ "mute": {"family": "boolean", "delivery": "discrete"},
40
+ "power": {"family": "boolean", "delivery": "discrete"},
41
+ "text": {"family": "text", "delivery": "discrete"},
42
+ "string": {"family": "text", "delivery": "discrete"},
43
+ "source": {"family": "enum", "delivery": "discrete"},
44
+ "preset": {"family": "enum", "delivery": "discrete"},
45
+ }
46
+
47
+
48
+ def export_control_type_contract():
49
+ """Return the versioned UI contract mirrored into the ControlPanel npm package."""
50
+ return {
51
+ "schemaVersion": CONTROL_TYPE_SCHEMA_VERSION,
52
+ "types": CONTROL_TYPE_DEFINITIONS,
53
+ }
54
+
55
+
56
+ def meter_route_from_descriptor(descriptor):
57
+ """Return validated Meter delivery metadata from a program descriptor.
58
+
59
+ A numeric ``.type`` alone cannot distinguish gain from realtime signal
60
+ telemetry. Meter therefore uses explicit ``.metadata.meter`` delivery
61
+ metadata, while the ordinary ``controlType`` hint remains unused.
62
+ """
63
+ if not isinstance(descriptor, dict) or descriptor.get(".kind") != "param":
64
+ return None
65
+ metadata = descriptor.get(".metadata", {})
66
+ route = metadata.get("meter") if isinstance(metadata, dict) else None
67
+ if route is None:
68
+ return None
69
+ if (
70
+ descriptor.get(".access") != "ro"
71
+ or descriptor.get(".type") not in ("integer", "float")
72
+ or descriptor.get(".enums")
73
+ ):
74
+ raise ValueError("meter descriptors must be readonly numeric parameters")
75
+ minimum = descriptor.get(".min")
76
+ maximum = descriptor.get(".max")
77
+ if (
78
+ not isinstance(minimum, (int, float)) or isinstance(minimum, bool)
79
+ or not isinstance(maximum, (int, float)) or isinstance(maximum, bool)
80
+ or not math.isfinite(minimum) or not math.isfinite(maximum)
81
+ or minimum >= maximum
82
+ or (descriptor.get(".type") == "integer" and
83
+ (type(minimum) is not int or type(maximum) is not int))
84
+ ):
85
+ raise ValueError("meter descriptors require finite increasing bounds")
86
+ if not isinstance(route, dict):
87
+ raise ValueError("meter metadata must be an object")
88
+
89
+ stream_id = route.get("streamId")
90
+ event_path = route.get("eventPath")
91
+ manifest_path = route.get("manifestPath")
92
+ if (
93
+ not isinstance(stream_id, str) or not 1 <= len(stream_id.strip()) <= 255
94
+ or not isinstance(event_path, str) or not event_path.startswith("//")
95
+ or not isinstance(manifest_path, str) or not manifest_path.startswith("//")
96
+ or event_path == manifest_path
97
+ ):
98
+ raise ValueError("meter metadata requires streamId, eventPath and manifestPath")
99
+ return {
100
+ "streamId": stream_id.strip(),
101
+ "eventPath": event_path,
102
+ "manifestPath": manifest_path,
103
+ }
104
+
105
+
106
+ class MeterStream:
107
+ """Bounded, latest-only numeric telemetry; publishers are supplied by adapters.
108
+
109
+ ``points`` maps paths to standard readonly numeric descriptors. Offering
110
+ samples never schedules a task. One consumer calls ``run`` or ``take_frame``.
111
+ """
112
+
113
+ def __init__(self, stream_id, points, unit="", publish_hz=30,
114
+ layout_version=1, clock=time.monotonic):
115
+ if not isinstance(stream_id, str) or not 1 <= len(stream_id.strip()) <= 255:
116
+ raise ValueError("stream_id must be a nonempty string of at most 255 characters")
117
+ if not isinstance(points, dict) or not 1 <= len(points) <= 256:
118
+ raise ValueError("points must contain 1..256 readonly numeric descriptors")
119
+ if not self._number(publish_hz) or not 1 <= publish_hz <= 60:
120
+ raise ValueError("publish_hz must be between 1 and 60")
121
+ if type(layout_version) is not int or not 1 <= layout_version <= 2147483647:
122
+ raise ValueError("layout_version must be a positive 32-bit integer")
123
+ if not isinstance(unit, str) or len(unit) > 32:
124
+ raise ValueError("unit must be a string of at most 32 characters")
125
+
126
+ self.stream_id = stream_id.strip()
127
+ self.publish_hz = publish_hz
128
+ self.layout_version = layout_version
129
+ self._clock = clock
130
+ self._points = []
131
+ paths = set()
132
+ for path, descriptor in points.items():
133
+ if not isinstance(path, str) or not 1 <= len(path.strip("/")) <= 1024:
134
+ raise ValueError("meter paths must be nonempty strings")
135
+ path = "/" + path.strip("/")
136
+ if path in paths:
137
+ raise ValueError("duplicate meter path")
138
+ paths.add(path)
139
+ if not isinstance(descriptor, dict) or descriptor.get(".kind") != "param" or \
140
+ descriptor.get(".access") != "ro" or \
141
+ descriptor.get(".type") not in ("integer", "float") or descriptor.get(".enums"):
142
+ raise ValueError("meters require readonly numeric parameters without enums")
143
+ minimum, maximum = descriptor.get(".min"), descriptor.get(".max")
144
+ if not self._number(minimum) or not self._number(maximum) or minimum >= maximum:
145
+ raise ValueError("meter descriptors require finite increasing bounds")
146
+ if descriptor[".type"] == "integer" and \
147
+ (type(minimum) is not int or type(maximum) is not int):
148
+ raise ValueError("integer meter bounds must be integers")
149
+ self._points.append({"path": path, "type": descriptor[".type"],
150
+ "min": minimum, "max": maximum, "unit": unit})
151
+
152
+ self._lock = threading.Lock()
153
+ self._epoch = uuid.uuid4().hex
154
+ self._seq = 0
155
+ self._latest = None
156
+ self._pending = False
157
+ self._next_publish_at = 0
158
+ self._closed = False
159
+ self._running = False
160
+ self._offered = self._overwritten = self._taken = 0
161
+
162
+ @staticmethod
163
+ def _number(value):
164
+ if type(value) is int:
165
+ return abs(value) <= 9007199254740991
166
+ return type(value) is float and math.isfinite(value)
167
+
168
+ @staticmethod
169
+ def _copy_frame(frame):
170
+ return None if frame is None else {**frame, "values": list(frame["values"]),
171
+ "valid": list(frame["valid"])}
172
+
173
+ def manifest(self):
174
+ with self._lock:
175
+ return {"kind": "meter-manifest", "streamId": self.stream_id,
176
+ "epoch": self._epoch, "layoutVersion": self.layout_version,
177
+ "publishHz": self.publish_hz, "clock": "monotonic-ms",
178
+ "points": [dict(point) for point in self._points]}
179
+
180
+ def offer_frame(self, values, valid=None, sample_time=None):
181
+ """Validate the entire frame before replacing the latest slot, from any thread."""
182
+ if not isinstance(values, (list, tuple)) or len(values) != len(self._points):
183
+ raise ValueError("frame length must match the meter manifest")
184
+ valid = [True] * len(values) if valid is None else valid
185
+ if not isinstance(valid, (list, tuple)) or len(valid) != len(values) or \
186
+ any(type(flag) is not bool for flag in valid):
187
+ raise ValueError("valid must contain one boolean per meter")
188
+ normalized = []
189
+ for point, value, flag in zip(self._points, values, valid):
190
+ if not flag:
191
+ normalized.append(None)
192
+ continue
193
+ if not self._number(value) or not point["min"] <= value <= point["max"] or \
194
+ (point["type"] == "integer" and type(value) is not int):
195
+ raise ValueError("valid meter samples must match their numeric descriptor")
196
+ normalized.append(value)
197
+ sample_time = self._clock() * 1000 if sample_time is None else sample_time
198
+ if not self._number(sample_time) or sample_time < 0:
199
+ raise ValueError("sample_time must be finite nonnegative monotonic milliseconds")
200
+ with self._lock:
201
+ if self._closed:
202
+ return False
203
+ self._replace(normalized, list(valid), sample_time)
204
+ return True
205
+
206
+ def _replace(self, values, valid, sample_time):
207
+ self._overwritten += int(self._pending)
208
+ self._offered += 1
209
+ self._seq += 1
210
+ self._latest = {"kind": "meter-frame", "streamId": self.stream_id,
211
+ "epoch": self._epoch, "seq": self._seq,
212
+ "layoutVersion": self.layout_version, "sampleTime": sample_time,
213
+ "values": values, "valid": valid}
214
+ self._pending = True
215
+
216
+ def snapshot(self):
217
+ with self._lock:
218
+ return self._copy_frame(self._latest)
219
+
220
+ def take_frame(self):
221
+ """Return at most one current frame per period; never catch up missed periods."""
222
+ with self._lock:
223
+ now = self._clock()
224
+ if self._closed or not self._pending or now < self._next_publish_at:
225
+ return None
226
+ self._pending = False
227
+ self._next_publish_at = now + 1 / self.publish_hz
228
+ self._taken += 1
229
+ return self._copy_frame(self._latest)
230
+
231
+ def reset(self):
232
+ """Invalidate all channels and change epoch after a source reconnect."""
233
+ with self._lock:
234
+ if self._closed:
235
+ return False
236
+ self._epoch = uuid.uuid4().hex
237
+ self._seq = 0
238
+ self._next_publish_at = 0
239
+ self._replace([None] * len(self._points), [False] * len(self._points),
240
+ self._clock() * 1000)
241
+ return True
242
+
243
+ def stats(self):
244
+ with self._lock:
245
+ return {"offered": self._offered, "overwritten": self._overwritten,
246
+ "taken": self._taken, "pending": int(self._pending)}
247
+
248
+ def close(self):
249
+ with self._lock:
250
+ self._closed = True
251
+ self._pending = False
252
+ self._latest = None
253
+
254
+ async def run(self, publish):
255
+ """One adapter-owned publisher task; exceptions propagate to its supervisor."""
256
+ if not callable(publish):
257
+ raise ValueError("publish must be callable")
258
+ with self._lock:
259
+ if self._running:
260
+ raise RuntimeError("meter stream already has a publisher")
261
+ self._running = True
262
+ try:
263
+ while not self._closed:
264
+ frame = self.take_frame()
265
+ if frame is not None:
266
+ result = publish(frame)
267
+ if inspect.isawaitable(result):
268
+ await result
269
+ await asyncio.sleep(1 / self.publish_hz)
270
+ finally:
271
+ with self._lock:
272
+ self._running = False
273
+
274
+
275
+ class ProgramControl:
276
+ """一个由 program.json metadata 描述的可控参数。"""
277
+
278
+ def __init__(self, path, descriptor):
279
+ metadata = descriptor.get(".metadata", {})
280
+ if not isinstance(metadata, dict):
281
+ metadata = {}
282
+
283
+ self.path = "/" + str(path).strip("/")
284
+ self.name = self.path.rsplit("/", 1)[-1]
285
+ self.value_type = descriptor.get(".type")
286
+ self.access = descriptor.get(".access", "c")
287
+ self.minimum = descriptor.get(".min")
288
+ self.maximum = descriptor.get(".max")
289
+ self.enums = tuple(descriptor.get(".enums", ()))
290
+ self.meter = meter_route_from_descriptor(descriptor)
291
+ self.control_type = infer_control_type(descriptor)
292
+ definition = CONTROL_TYPE_DEFINITIONS.get(self.control_type, {})
293
+ self.control_family = definition.get("family", "")
294
+ self.control_delivery = definition.get("delivery", "")
295
+ # 在普通 descriptor 中这是最终协议号;声明了
296
+ # protocolChannelMode=per-element 的数组会在注册表展开时把它解释为
297
+ # 通道内编号,再换算成最终协议号。
298
+ self.protocol_channel_ordinal = self._parse_optional_int(
299
+ metadata.get("protocolChannel")
300
+ )
301
+ self.protocol_channel = self.protocol_channel_ordinal
302
+ self.requires_ack = self._parse_bool(metadata.get("requiresAck", False))
303
+
304
+ self._refresh_key()
305
+
306
+ def _refresh_key(self):
307
+ """根据当前的最终协议号重建发送队列 key。"""
308
+ if self.control_type and self.protocol_channel is not None:
309
+ self.key = f"{self.control_type}:{self.protocol_channel}"
310
+ else:
311
+ self.key = f"{self.control_type}:{self.path}"
312
+
313
+ def set_protocol_channel(self, protocol_channel):
314
+ """设置数组展开后的最终协议号,并同步更新队列 key。"""
315
+ self.protocol_channel = int(protocol_channel)
316
+ self._refresh_key()
317
+
318
+ @staticmethod
319
+ def _parse_optional_int(value):
320
+ if value is None or value == "":
321
+ return None
322
+ try:
323
+ return int(value)
324
+ except (TypeError, ValueError) as ex:
325
+ raise ValueError(f"protocolChannel must be an integer: {value!r}") from ex
326
+
327
+ @staticmethod
328
+ def _parse_bool(value):
329
+ if isinstance(value, bool):
330
+ return value
331
+ return str(value).strip().lower() in ("1", "true", "yes", "on")
332
+
333
+
334
+ def infer_control_type(descriptor):
335
+ """Infer the transport family from a standard parameter descriptor.
336
+
337
+ ``controlType`` used to be an optional driver hint. It is deliberately not
338
+ consulted here: the descriptor's value type, enum list, and finite bounds
339
+ are the source of truth shared with the visual binding catalog.
340
+ """
341
+ if not isinstance(descriptor, dict) or descriptor.get(".kind") != "param":
342
+ return ""
343
+ if meter_route_from_descriptor(descriptor) is not None:
344
+ return "meter"
345
+ value_type = str(descriptor.get(".type", "")).strip().lower()
346
+ enums = descriptor.get(".enums", ())
347
+ has_enums = isinstance(enums, (list, tuple)) and bool(enums)
348
+ minimum = descriptor.get(".min")
349
+ maximum = descriptor.get(".max")
350
+ if value_type == "boolean":
351
+ return "toggle"
352
+ if value_type == "integer" and minimum == 0 and maximum == 1 and not has_enums:
353
+ return "toggle"
354
+ if value_type == "enum" or (has_enums and value_type in ("integer", "string")):
355
+ return "source"
356
+ if value_type in ("integer", "float"):
357
+ return "slider"
358
+ if value_type in ("string", "dstring"):
359
+ return "text"
360
+ return ""
361
+
362
+
363
+ class ProgramControlRegistry:
364
+ """从 MUSE program descriptor 建立可控参数索引。
365
+
366
+ 标准字段决定类型、范围和读写权限,``.metadata`` 只补充协议编号和 ACK 策略。
367
+ 不把具体参数名称写死在驱动代码中。
368
+ """
369
+
370
+ def __init__(self, descriptor):
371
+ if not isinstance(descriptor, dict):
372
+ raise ValueError("program descriptor must be an object")
373
+ self.descriptor = descriptor
374
+ self.controls = []
375
+ self._by_path = {}
376
+ self._walk(descriptor, [])
377
+
378
+ @staticmethod
379
+ def _normalize_path(path):
380
+ return "/" + str(path).strip("/")
381
+
382
+ @classmethod
383
+ def _protocol_channel_ordinals(cls, node):
384
+ """读取一个数组 prototype 中声明的通道内协议编号。"""
385
+ if not isinstance(node, dict):
386
+ return []
387
+ if node.get(".kind") == "param":
388
+ metadata = node.get(".metadata", {})
389
+ if not isinstance(metadata, dict):
390
+ return []
391
+ ordinal = ProgramControl._parse_optional_int(
392
+ metadata.get("protocolChannel")
393
+ )
394
+ return [ordinal] if ordinal is not None else []
395
+ if node.get(".kind") == "array":
396
+ return []
397
+
398
+ ordinals = []
399
+ for name, child in node.items():
400
+ if not str(name).startswith("."):
401
+ ordinals.extend(cls._protocol_channel_ordinals(child))
402
+ return ordinals
403
+
404
+ @classmethod
405
+ def _array_protocol_stride(cls, node):
406
+ """验证 per-element 编号为 1..n,并返回 n。"""
407
+ metadata = node.get(".metadata", {}) if isinstance(node, dict) else {}
408
+ if not isinstance(metadata, dict) or metadata.get("protocolChannelMode") != "per-element":
409
+ return None
410
+
411
+ ordinals = cls._protocol_channel_ordinals(node.get(".prototype"))
412
+ expected = list(range(1, len(ordinals) + 1))
413
+ if not ordinals or sorted(ordinals) != expected:
414
+ raise ValueError(
415
+ "per-element protocolChannel values must be a unique sequence 1..n"
416
+ )
417
+ return len(ordinals)
418
+
419
+ def _walk(self, node, path, array_protocol=None):
420
+ if not isinstance(node, dict):
421
+ return
422
+
423
+ kind = node.get(".kind")
424
+ if kind == "array":
425
+ prototype = node.get(".prototype")
426
+ size = node.get(".size", 0)
427
+ if isinstance(size, int) and size > 0 and prototype is not None:
428
+ stride = self._array_protocol_stride(node)
429
+ for index in range(size):
430
+ child_protocol = array_protocol
431
+ if stride is not None:
432
+ child_protocol = {
433
+ "offset": index * stride,
434
+ "stride": stride,
435
+ }
436
+ self._walk(
437
+ prototype,
438
+ path + [str(index)],
439
+ child_protocol,
440
+ )
441
+ return
442
+
443
+ if kind == "param":
444
+ control = ProgramControl("/".join(path), node)
445
+ if control.control_type:
446
+ if array_protocol and control.protocol_channel is not None:
447
+ ordinal = control.protocol_channel
448
+ if not 1 <= ordinal <= array_protocol["stride"]:
449
+ raise ValueError(
450
+ f"protocolChannel must be between 1 and "
451
+ f"{array_protocol['stride']} for {control.path}"
452
+ )
453
+ control.set_protocol_channel(
454
+ array_protocol["offset"] + ordinal
455
+ )
456
+ self.controls.append(control)
457
+ self._by_path[control.path] = control
458
+ return
459
+
460
+ # Root descriptor 和 obj 都只递归用户定义的非点号字段。
461
+ for name, child in node.items():
462
+ if not str(name).startswith("."):
463
+ self._walk(child, path + [str(name)], array_protocol)
464
+
465
+ def resolve(self, path):
466
+ """按 MUSE 内部零起始路径查找一个 metadata 控件。"""
467
+ return self._by_path.get(self._normalize_path(path))
468
+
469
+ def protocol_channels(self, control_types=None):
470
+ """返回 metadata 中声明过的协议控制号。"""
471
+ allowed = None
472
+ if control_types is not None:
473
+ allowed = {str(item).strip().lower() for item in control_types}
474
+
475
+ channels = {
476
+ control.protocol_channel
477
+ for control in self.controls
478
+ if control.protocol_channel is not None
479
+ and (allowed is None or control.control_type in allowed)
480
+ }
481
+ return tuple(sorted(channels))
482
+
483
+ def control_types_for_channel(self, protocol_channel):
484
+ return {
485
+ control.control_type
486
+ for control in self.controls
487
+ if control.protocol_channel == int(protocol_channel)
488
+ }
489
+
490
+ def paths_for(self, processor_slot, control_type, protocol_channel):
491
+ """返回某设备/协议控制号对应的所有 descriptor 参数路径。"""
492
+ prefix = f"/processors/{int(processor_slot)}/"
493
+ normalized_type = str(control_type).strip().lower()
494
+ return tuple(
495
+ control.path
496
+ for control in self.controls
497
+ if control.path.startswith(prefix)
498
+ and control.control_type == normalized_type
499
+ and control.protocol_channel == int(protocol_channel)
500
+ )
501
+
502
+
503
+ class AsyncTcpDeviceClient(ABC):
504
+ """一台 TCP 设备的通用连接客户端。
505
+ 子类必须实现 ``handle_feedback``,通常还会覆盖 ``status_commands``。
506
+ 需要登录的设备可覆盖 ``handshake``;使用二进制或非逐行协议的设备可覆盖
507
+ ``read_message`` 和 ``encode_command``。
508
+ """
509
+
510
+ # 离散控制必须逐条保序;连续控制只需要追踪每个控制号的最新目标。
511
+ DISCRETE_CONTROL_TYPES = frozenset(
512
+ ("discrete", "power", "source", "preset", "toggle")
513
+ )
514
+ CONTINUOUS_CONTROL_TYPES = frozenset(
515
+ ("continuous", "slider", "dimmer", "position")
516
+ )
517
+
518
+ def __init__(self, manager, config):
519
+ # 保存所属管理器;客户端通过管理器取得日志、Event Loop 和调度能力。
520
+ self.manager = manager
521
+
522
+ # 可选的 program.json 控件索引由具体 manager 注入;没有描述符的旧驱动
523
+ # 仍可继续使用连接和发送框架。
524
+ self.control_registry = getattr(manager, "control_registry", None)
525
+
526
+ # slot 是设备在 Script-as-Device 数组中的固定索引。
527
+ self.slot = config["slot"]
528
+
529
+ # device_id 用于日志和设备身份识别,name 是面向用户的名称。
530
+ self.device_id = config["id"]
531
+ self.name = config["name"]
532
+
533
+ # 目标设备的 TCP 地址。
534
+ self.host = config["host"]
535
+ self.port = config["port"]
536
+
537
+ # 登录信息是可选项;不需要登录的协议保持为空字符串。
538
+ self.username = config.get("username", "")
539
+ self.password = config.get("password", "")
540
+
541
+ # 连接失败后的等待时间,以及协议登录阶段的超时时间。
542
+ self.reconnect_delay = config.get("reconnect_delay_seconds", 10)
543
+ self.login_timeout = config.get("login_timeout_seconds", 10)
544
+
545
+ # 状态轮询间隔;0 表示关闭定时轮询。
546
+ self.status_poll = config.get("status_poll_seconds", 0)
547
+
548
+ # 是否在 TCP 登录完成后主动执行一次 status_commands()。
549
+ self.query_on_connect = config.get("query_on_connect", True)
550
+
551
+ # 限制待发送命令数量,防止设备响应慢时队列无限增长。
552
+ self.command_queue_size = config.get("command_queue_size", 100)
553
+
554
+ # 高频可合并命令的最小发送间隔;0.04 秒即每秒最多 25 次更新。
555
+ self.latest_command_interval = config.get(
556
+ "latest_command_interval_seconds",
557
+ 0.04,
558
+ )
559
+
560
+ # 高频命令和反馈日志只用于现场排查,正常运行默认关闭以减少 I/O 开销。
561
+ self.traffic_debug = config.get("traffic_debug", False)
562
+
563
+ # reader/writer 由 asyncio.open_connection() 建立连接后赋值。
564
+ self.reader = None
565
+ self.writer = None
566
+
567
+ # connected_event 表示当前连接可用;command_queue 串行保存发送任务。
568
+ self.connected_event = None
569
+ self.command_queue = None
570
+
571
+ # 高频参数(如音量滑块)可按 key 合并,队列中只保留同 key 的最新值。
572
+ self._latest_commands = {}
573
+ self._queued_latest_keys = set()
574
+ self._latest_command_marker = object()
575
+ self._latest_sent_at = {}
576
+
577
+ # is_online 是提供给上层发布的布尔状态。
578
+ self.is_online = False
579
+
580
+ # running 控制永久重连循环;设为 False 后不再重连。
581
+ self.running = True
582
+
583
+ @property
584
+ def log_name(self):
585
+ """返回同时包含设备 ID 和数组槽位的日志名称。"""
586
+ return f"{self.device_id}[{self.slot}]"
587
+
588
+ async def connect_loop(self, initial_delay=0):
589
+ """持续执行连接、运行、清理和延时重连的完整生命周期。"""
590
+ # Event 和 Queue 必须在实际运行它们的 Event Loop 线程中创建。
591
+ self.connected_event = asyncio.Event()
592
+ self.command_queue = asyncio.Queue(maxsize=self.command_queue_size)
593
+
594
+ # 多设备启动时错开连接,避免瞬间同时创建大量 TCP 连接。
595
+ if initial_delay:
596
+ await asyncio.sleep(initial_delay)
597
+
598
+ # 每次连接断开后,只要 running 仍为 True,就继续下一轮重连。
599
+ while self.running:
600
+ # 保存本次连接创建的监听、发送和轮询任务,方便统一取消和回收。
601
+ connection_tasks = []
602
+ try:
603
+ # 输出当前连接目标,便于在 MUSE 日志中定位具体设备。
604
+ self.manager.log.info(
605
+ f"{self.log_name} connecting to {self.host}:{self.port}"
606
+ )
607
+
608
+ # 异步建立 TCP 连接;等待期间不会阻塞其他设备任务。
609
+ self.reader, self.writer = await asyncio.open_connection(
610
+ self.host,
611
+ self.port,
612
+ )
613
+
614
+ # 执行子类定义的登录或初始化握手;默认实现什么也不做。
615
+ await self.handshake()
616
+
617
+ # 握手成功后才把设备标记为可发送命令和在线。
618
+ self.connected_event.set()
619
+ self._publish_online(True)
620
+
621
+ # 某些设备查询会返回大量反馈;是否上线即查询由配置决定。
622
+ if self.query_on_connect:
623
+ initial_commands = self.status_commands()
624
+ if initial_commands:
625
+ await self._send_sequence_direct(initial_commands)
626
+
627
+ # 监听任务负责收反馈;发送任务负责按队列顺序写 socket。
628
+ connection_tasks = [
629
+ asyncio.create_task(self._socket_listener()),
630
+ asyncio.create_task(self._socket_sender()),
631
+ ]
632
+
633
+ # status_poll 大于 0 时,再增加一个周期状态查询任务。
634
+ if self.status_poll > 0:
635
+ connection_tasks.append(
636
+ asyncio.create_task(self._status_poll_loop())
637
+ )
638
+
639
+ # 任一任务结束通常表示连接已失效,需要结束同组其他任务并重连。
640
+ done, pending = await asyncio.wait(
641
+ connection_tasks,
642
+ return_when=asyncio.FIRST_COMPLETED,
643
+ )
644
+
645
+ # 取消仍在运行的同连接任务。
646
+ for task in pending:
647
+ task.cancel()
648
+
649
+ # 读取已结束任务的结果;其中的异常会进入下面的异常处理。
650
+ for task in done:
651
+ task.result()
652
+ except asyncio.CancelledError:
653
+ # 外部取消 connect_loop 时永久退出,不再进入重连流程。
654
+ self.running = False
655
+ raise
656
+ except Exception as ex:
657
+ # 网络、登录、读取或发送错误都在这里记录,然后进入 finally 清理。
658
+ self.manager.log.warn(f"{self.log_name} connection error: {ex}")
659
+ finally:
660
+ # 确保本次连接的所有子任务都已取消。
661
+ for task in connection_tasks:
662
+ if not task.done():
663
+ task.cancel()
664
+
665
+ # 等待取消完成,并收集异常,避免出现未处理的 Task 警告。
666
+ if connection_tasks:
667
+ await asyncio.gather(
668
+ *connection_tasks,
669
+ return_exceptions=True,
670
+ )
671
+
672
+ # 清除在线事件,后续进入队列的命令将被拒绝。
673
+ if self.connected_event:
674
+ self.connected_event.clear()
675
+
676
+ # 向具体驱动发布离线状态,关闭 socket,并丢弃旧连接遗留命令。
677
+ self._publish_online(False)
678
+ await self._close_writer()
679
+ self._clear_command_queue()
680
+
681
+ # 正常断线或异常后,等待配置的时间再尝试下一次连接。
682
+ if self.running:
683
+ self.manager.log.info(
684
+ f"{self.log_name} reconnecting in "
685
+ f"{self.reconnect_delay} seconds"
686
+ )
687
+ await asyncio.sleep(self.reconnect_delay)
688
+
689
+ async def handshake(self):
690
+ """可选的协议登录或初始化钩子,由具体设备子类覆盖。"""
691
+
692
+ async def read_message(self):
693
+ """读取一行文本反馈;其他分帧方式应在子类中覆盖。"""
694
+
695
+ # readline() 等待换行符;远端关闭连接时会返回空字节串。
696
+ data = await self.reader.readline()
697
+ if not data:
698
+ return None
699
+
700
+ # 忽略无法解码的字节,并去掉行尾换行符及空白。
701
+ return data.decode("utf-8", "ignore").strip()
702
+
703
+ def encode_command(self, command):
704
+ """把命令转换为字节;二进制协议可以在子类中覆盖。"""
705
+
706
+ # 已经是 bytes 时直接返回,避免重复编码。
707
+ if isinstance(command, bytes):
708
+ return command
709
+
710
+ # 字符串及其他对象统一转成 UTF-8 文本字节。
711
+ return str(command).encode("utf-8")
712
+
713
+ def status_commands(self):
714
+ """返回 ``(发送前延时秒数, 命令)`` 列表;默认没有状态命令。"""
715
+ return []
716
+
717
+ @abstractmethod
718
+ def handle_feedback(self, response):
719
+ """解析并发布一条完整反馈;具体设备驱动必须实现。"""
720
+
721
+ async def _socket_listener(self):
722
+ """持续读取 socket,并把完整消息交给协议层处理。"""
723
+ while True:
724
+ # read_message() 允许子类自定义文本行、定长包或其他分帧方式。
725
+ response = await self.read_message()
726
+
727
+ # None 表示远端已关闭 socket,本任务返回后会触发重连。
728
+ if response is None:
729
+ return
730
+
731
+ # 空消息不处理;非空消息交给具体协议解析。
732
+ if response:
733
+ self.handle_feedback(response)
734
+
735
+ async def _socket_sender(self):
736
+ """从本设备的命令队列逐组取出并串行发送。"""
737
+ while True:
738
+ # 队列为空时异步等待,不会占用 CPU。
739
+ queue_item = await self.command_queue.get()
740
+ try:
741
+ commands = queue_item
742
+ latest_key = None
743
+
744
+ # latest 标记不直接携带命令。必要时先等待发送窗口,再取得
745
+ # 该 key 此刻最新的值,避免发送滑杆移动过程中已经过期的中间值。
746
+ if (
747
+ isinstance(queue_item, tuple)
748
+ and len(queue_item) == 2
749
+ and queue_item[0] is self._latest_command_marker
750
+ ):
751
+ latest_key = queue_item[1]
752
+ last_sent = self._latest_sent_at.get(latest_key)
753
+ if last_sent is not None and self.latest_command_interval:
754
+ elapsed = asyncio.get_running_loop().time() - last_sent
755
+ remaining = self.latest_command_interval - elapsed
756
+ if remaining > 0:
757
+ await asyncio.sleep(remaining)
758
+
759
+ commands = self._latest_commands.pop(latest_key, None)
760
+
761
+ # 断线清理等情况可能已经移除待发值。
762
+ if not commands:
763
+ if latest_key is not None:
764
+ self._queued_latest_keys.discard(latest_key)
765
+ continue
766
+
767
+ # 同一组命令按照指定延时和顺序发送,避免 socket 写入交叉。
768
+ await self._send_sequence_direct(commands)
769
+
770
+ if latest_key is not None:
771
+ self._latest_sent_at[latest_key] = (
772
+ asyncio.get_running_loop().time()
773
+ )
774
+
775
+ # 发送期间又收到新值时,把同一个标记放到队尾;这样其他
776
+ # 按键或通道也有机会先发送,同时该 key 仍不会产生多个标记。
777
+ if latest_key in self._latest_commands:
778
+ try:
779
+ self.command_queue.put_nowait(
780
+ (self._latest_command_marker, latest_key)
781
+ )
782
+ except asyncio.QueueFull:
783
+ self.manager.log.warn(
784
+ f"{self.log_name} command queue is full"
785
+ )
786
+ self._latest_commands.pop(latest_key, None)
787
+ self._queued_latest_keys.discard(latest_key)
788
+ else:
789
+ self._queued_latest_keys.discard(latest_key)
790
+ finally:
791
+ # 无论发送成功还是异常,都通知 Queue 当前项目已处理完。
792
+ self.command_queue.task_done()
793
+
794
+ async def _status_poll_loop(self):
795
+ """按照配置间隔周期性地把状态查询放入发送队列。"""
796
+ while True:
797
+ # 异步休眠不会阻塞其他设备的网络任务。
798
+ await asyncio.sleep(self.status_poll)
799
+
800
+ # 每次重新生成查询命令,允许子类根据当前状态动态决定内容。
801
+ commands = self.status_commands()
802
+ if commands:
803
+ # 走统一队列,确保轮询命令不会与控制命令同时写 socket。
804
+ await self.command_queue.put(commands)
805
+
806
+ async def _send_sequence_direct(self, commands):
807
+ """在当前协程中按顺序发送一组带延时的命令。
808
+
809
+ 普通项目为 ``(delay, command)``。需要协议 ACK 的离散命令可以使用
810
+ ``(delay, command, True)``;子类同时覆盖 ``wait_for_command_ack``。
811
+ """
812
+ for item in commands:
813
+ if len(item) == 2:
814
+ delay, command = item
815
+ requires_ack = False
816
+ elif len(item) == 3:
817
+ delay, command, requires_ack = item
818
+ else:
819
+ raise ValueError("command item must contain 2 or 3 values")
820
+
821
+ if delay:
822
+ await asyncio.sleep(delay)
823
+ await self._send_direct(command)
824
+
825
+ # ACK 等待发生在同一设备的串行 sender 中,因此后一条离散命令
826
+ # 不会越过当前命令。监听协程仍可并行读取并唤醒子类的 ACK Event。
827
+ if requires_ack:
828
+ await self.wait_for_command_ack(command)
829
+
830
+ async def wait_for_command_ack(self, command):
831
+ """等待一条离散命令的协议 ACK;需要 ACK 的子类必须覆盖。"""
832
+ raise NotImplementedError(
833
+ f"{self.log_name} requires wait_for_command_ack()"
834
+ )
835
+
836
+ async def _send_direct(self, command):
837
+ """向当前已连接的 socket 写入一条命令。"""
838
+
839
+ # writer 不存在或正在关闭时,主动抛错并触发连接生命周期重建。
840
+ if not self.writer or self.writer.is_closing():
841
+ raise ConnectionError("socket writer is not ready")
842
+
843
+ # 现场排查时才记录每条原始命令;滑杆运行时逐条写日志会增加延迟。
844
+ if self.traffic_debug:
845
+ self.manager.log.info(f"{self.log_name} sending: {command}")
846
+
847
+ # 协议层负责把命令编码为 bytes;write() 只写入本地发送缓冲区。
848
+ self.writer.write(self.encode_command(command))
849
+
850
+ # drain() 等待缓冲区可继续写,形成异步流控。
851
+ await self.writer.drain()
852
+
853
+ async def enqueue(self, commands):
854
+ """把一组命令加入本设备发送队列。"""
855
+
856
+ # 离线时不缓存控制命令,避免重连后执行已经过期的操作。
857
+ if not self.connected_event or not self.connected_event.is_set():
858
+ self.manager.log.warn(f"{self.log_name} command dropped while offline")
859
+ return False
860
+
861
+ try:
862
+ # 使用非阻塞入队;队列满时立即报告,而不是无限等待。
863
+ self.command_queue.put_nowait(commands)
864
+ return True
865
+ except asyncio.QueueFull:
866
+ self.manager.log.warn(f"{self.log_name} command queue is full")
867
+ return False
868
+
869
+ async def enqueue_latest(self, key, commands):
870
+ """按 key 合并高频命令,队列尚未发送时只保留最新一组。"""
871
+ if not self.connected_event or not self.connected_event.is_set():
872
+ self.manager.log.warn(f"{self.log_name} command dropped while offline")
873
+ return False
874
+
875
+ # 无论 key 是否已在队列中,都先用最新命令覆盖旧值。
876
+ self._latest_commands[key] = commands
877
+
878
+ # 该 key 已有一个队列标记时,不再额外占用队列位置。
879
+ if key in self._queued_latest_keys:
880
+ return True
881
+
882
+ try:
883
+ self.command_queue.put_nowait((self._latest_command_marker, key))
884
+ self._queued_latest_keys.add(key)
885
+ return True
886
+ except asyncio.QueueFull:
887
+ self._latest_commands.pop(key, None)
888
+ self.manager.log.warn(f"{self.log_name} command queue is full")
889
+ return False
890
+
891
+ async def _close_writer(self):
892
+ """关闭当前 StreamWriter,并清空 reader/writer 引用。"""
893
+
894
+ # 先保存局部引用,再立即清空实例状态,防止其他逻辑继续使用旧连接。
895
+ writer = self.writer
896
+ self.reader = None
897
+ self.writer = None
898
+
899
+ # 从未建连成功时没有 writer,无需继续处理。
900
+ if not writer:
901
+ return
902
+
903
+ try:
904
+ # 请求关闭 socket,并等待底层资源释放完成。
905
+ writer.close()
906
+ await writer.wait_closed()
907
+ except Exception as ex:
908
+ # 关闭阶段的异常只记录,不阻止后续重连。
909
+ self.manager.log.warn(f"{self.log_name} socket close error: {ex}")
910
+
911
+ def _clear_command_queue(self):
912
+ """断线时清除队列,避免重连后发送旧命令。"""
913
+
914
+ # 初始化连接失败时队列可能尚不可用。
915
+ if not self.command_queue:
916
+ self._latest_commands.clear()
917
+ self._queued_latest_keys.clear()
918
+ return
919
+
920
+ # 持续非阻塞取出,直到 QueueEmpty 表示已经清空。
921
+ while True:
922
+ try:
923
+ self.command_queue.get_nowait()
924
+ self.command_queue.task_done()
925
+ except asyncio.QueueEmpty:
926
+ break
927
+
928
+ self._latest_commands.clear()
929
+ self._queued_latest_keys.clear()
930
+ self._latest_sent_at.clear()
931
+
932
+ def _publish_online(self, value):
933
+ """仅在在线状态实际变化时通知上层管理器。"""
934
+ value = bool(value)
935
+
936
+ # 状态没有变化时不重复触发 export.update。
937
+ if self.is_online == value:
938
+ return
939
+
940
+ # 保存新状态并输出设备级日志。
941
+ self.is_online = value
942
+ self.manager.log.info(
943
+ f"{self.log_name} online state changed to {value}"
944
+ )
945
+
946
+ # 具体管理器决定如何向 MUSE Script-as-Device 发布状态。
947
+ self.manager.online_state_changed(self)
948
+
949
+ def query_status(self):
950
+ """把当前设备的状态查询命令加入发送队列。"""
951
+
952
+ # 由具体协议生成查询命令。
953
+ commands = self.status_commands()
954
+ if not commands:
955
+ return False
956
+
957
+ # 通过管理器跨线程安全地送入共享 Event Loop。
958
+ return self.manager.schedule(self, commands)
959
+
960
+ def send_control(
961
+ self,
962
+ control_type,
963
+ key,
964
+ commands,
965
+ requires_ack=False,
966
+ ):
967
+ """按控件语义选择离散 FIFO 或连续 latest-wins 策略。
968
+
969
+ ``key`` 对连续控制必须稳定且唯一,例如 ``slider:2``。同一设备、
970
+ 同一 key 在队列里最多只有一个标记,尚未发送的旧目标会被覆盖。
971
+ """
972
+ normalized_type = str(control_type).strip().lower()
973
+ commands = list(commands)
974
+
975
+ if normalized_type in self.CONTINUOUS_CONTROL_TYPES:
976
+ if requires_ack:
977
+ raise ValueError("continuous controls cannot wait for ACK")
978
+ if not key:
979
+ raise ValueError("continuous controls require a stable key")
980
+ return self.manager.schedule_latest(self, str(key), commands)
981
+
982
+ if normalized_type in self.DISCRETE_CONTROL_TYPES:
983
+ if requires_ack:
984
+ commands = [
985
+ (delay, command, True)
986
+ for delay, command in commands
987
+ ]
988
+ return self.manager.schedule(self, commands)
989
+
990
+ raise ValueError(f"unsupported control type: {control_type}")
991
+
992
+ def normalize_control_value(self, control, value):
993
+ """按 descriptor 的标准类型和范围校验/规范化一个输入值。"""
994
+ value_type = control.value_type
995
+ if value_type == "boolean":
996
+ if isinstance(value, bool):
997
+ normalized = value
998
+ elif isinstance(value, (int, float)) and value in (0, 1):
999
+ normalized = value == 1
1000
+ elif isinstance(value, str):
1001
+ normalized_value = value.strip().lower()
1002
+ if normalized_value in ("true", "1", "on"):
1003
+ normalized = True
1004
+ elif normalized_value in ("false", "0", "off"):
1005
+ normalized = False
1006
+ else:
1007
+ raise ValueError(f"invalid boolean value: {value!r}")
1008
+ else:
1009
+ raise ValueError(f"invalid boolean value: {value!r}")
1010
+ elif value_type == "integer":
1011
+ normalized = int(value)
1012
+ elif value_type == "float":
1013
+ normalized = float(value)
1014
+ else:
1015
+ normalized = str(value)
1016
+
1017
+ if isinstance(normalized, (int, float)) and not isinstance(normalized, bool):
1018
+ if control.minimum is not None:
1019
+ normalized = max(control.minimum, normalized)
1020
+ if control.maximum is not None:
1021
+ normalized = min(control.maximum, normalized)
1022
+
1023
+ if control.enums and normalized not in control.enums:
1024
+ raise ValueError(f"value {normalized!r} is not in {control.enums!r}")
1025
+ return normalized
1026
+
1027
+ def send_descriptor_control(self, control, value):
1028
+ """按标准 descriptor 选择策略,再交给协议子类编码实际设备命令。"""
1029
+ if control is None or not control.control_type:
1030
+ raise ValueError("control type could not be inferred from descriptor")
1031
+ if control.control_type == "meter" or control.meter:
1032
+ raise ValueError("meter controls are read-only realtime telemetry")
1033
+ if control.access not in ("rw", "wo"):
1034
+ raise ValueError(f"control is not writable: {control.path}")
1035
+
1036
+ normalized = self.normalize_control_value(control, value)
1037
+ commands = self.encode_descriptor_control(control, normalized)
1038
+ return self.send_control(
1039
+ control.control_type,
1040
+ control.key,
1041
+ commands,
1042
+ requires_ack=control.requires_ack,
1043
+ )
1044
+
1045
+ def encode_descriptor_control(self, control, value):
1046
+ """把 metadata 控件编码为协议命令;由具体设备子类实现。"""
1047
+ raise NotImplementedError(
1048
+ f"{self.log_name} does not implement {control.control_type!r} control"
1049
+ )
1050
+
1051
+ def send_raw(self, data):
1052
+ """不修改内容,直接把一条原始命令加入发送队列。"""
1053
+ return self.manager.schedule(self, [(0, data)])
1054
+
1055
+
1056
+ class AsyncTcpDeviceManager(ABC):
1057
+ """在一个后台线程和共享 asyncio Event Loop 中管理多台 TCP 设备。"""
1058
+
1059
+ # 这些字段属于所有持久 TCP 设备的通用连接配置。
1060
+ DEFAULT_DEVICE_CONFIG = {
1061
+ "username": "",
1062
+ "password": "",
1063
+ "reconnect_delay_seconds": 10,
1064
+ "login_timeout_seconds": 10,
1065
+ "status_poll_seconds": 30,
1066
+ "query_on_connect": True,
1067
+ "command_queue_size": 100,
1068
+ "latest_command_interval_seconds": 0.04,
1069
+ "traffic_debug": False,
1070
+ }
1071
+
1072
+ @classmethod
1073
+ def load_config(
1074
+ cls,
1075
+ config_path,
1076
+ entries_key="devices",
1077
+ max_devices=100,
1078
+ id_prefix="device",
1079
+ ):
1080
+ """读取并标准化多台 TCP 设备的通用 JSON 配置。
1081
+
1082
+ 具体驱动只需指定设备数组名称、最大数量和默认 ID 前缀。
1083
+ 协议通道、命令编号等设备特有信息不应放在这个通用加载器中。
1084
+ """
1085
+
1086
+ # 读取项目目录中由具体驱动传入的 config.json。
1087
+ with open(config_path, "r", encoding="utf-8") as config_file:
1088
+ raw_config = json.load(config_file)
1089
+
1090
+ if not isinstance(raw_config, dict):
1091
+ raise ValueError("config root must be an object")
1092
+
1093
+ file_defaults = raw_config.get("defaults", {})
1094
+ entries = raw_config.get(entries_key)
1095
+
1096
+ if not isinstance(file_defaults, dict):
1097
+ raise ValueError("config.defaults must be an object")
1098
+ if not isinstance(entries, list):
1099
+ raise ValueError(f"config.{entries_key} must be an array")
1100
+ if len(entries) > max_devices:
1101
+ raise ValueError(
1102
+ f"At most {max_devices} {entries_key} are supported"
1103
+ )
1104
+
1105
+ # 用户的 defaults 可以覆盖基类默认值,单台设备又可以继续覆盖。
1106
+ defaults = dict(cls.DEFAULT_DEVICE_CONFIG)
1107
+ defaults.update(file_defaults)
1108
+
1109
+ connection_stagger = float(
1110
+ raw_config.get("connection_stagger_seconds", 0.05)
1111
+ )
1112
+ if connection_stagger < 0:
1113
+ raise ValueError("connection_stagger_seconds cannot be negative")
1114
+
1115
+ normalized_entries = []
1116
+ used_slots = set()
1117
+ used_ids = set()
1118
+
1119
+ for position, entry in enumerate(entries):
1120
+ if not isinstance(entry, dict):
1121
+ raise ValueError(f"{entries_key}[{position}] must be an object")
1122
+
1123
+ config = dict(defaults)
1124
+ config.update(entry)
1125
+ config.setdefault("slot", position)
1126
+ config.setdefault("enabled", True)
1127
+
1128
+ try:
1129
+ slot = int(config["slot"])
1130
+ except (TypeError, ValueError) as ex:
1131
+ raise ValueError(
1132
+ f"{entries_key}[{position}].slot must be an integer"
1133
+ ) from ex
1134
+
1135
+ config.setdefault("id", f"{id_prefix}-{slot + 1:03d}")
1136
+ config.setdefault("name", config["id"])
1137
+
1138
+ device_id = str(config["id"]).strip()
1139
+ enabled = config["enabled"]
1140
+ traffic_debug = config["traffic_debug"]
1141
+ query_on_connect = config["query_on_connect"]
1142
+
1143
+ if not isinstance(enabled, bool):
1144
+ raise ValueError(
1145
+ f"{entries_key}[{position}].enabled must be a boolean"
1146
+ )
1147
+ if not isinstance(traffic_debug, bool):
1148
+ raise ValueError(
1149
+ f"{entries_key}[{position}].traffic_debug must be a boolean"
1150
+ )
1151
+ if not isinstance(query_on_connect, bool):
1152
+ raise ValueError(
1153
+ f"{entries_key}[{position}].query_on_connect must be a boolean"
1154
+ )
1155
+ if slot < 0 or slot >= max_devices:
1156
+ raise ValueError(
1157
+ f"{entries_key}[{position}].slot must be 0-{max_devices - 1}"
1158
+ )
1159
+ if slot in used_slots:
1160
+ raise ValueError(f"Duplicate {entries_key} slot: {slot}")
1161
+ if not device_id:
1162
+ raise ValueError(f"{entries_key}[{position}].id cannot be empty")
1163
+ if device_id in used_ids:
1164
+ raise ValueError(f"Duplicate {entries_key} id: {device_id}")
1165
+
1166
+ config["slot"] = slot
1167
+ config["id"] = device_id
1168
+ config["name"] = str(config["name"])
1169
+ config["enabled"] = enabled
1170
+
1171
+ # disabled 条目只作为保留槽位,不必提供网络参数。
1172
+ if enabled:
1173
+ missing = [
1174
+ key for key in ("host", "port") if key not in config
1175
+ ]
1176
+ if missing:
1177
+ raise ValueError(
1178
+ f"{device_id} is missing: {', '.join(missing)}"
1179
+ )
1180
+
1181
+ try:
1182
+ config["host"] = str(config["host"]).strip()
1183
+ config["port"] = int(config["port"])
1184
+ config["username"] = str(config["username"])
1185
+ config["password"] = str(config["password"])
1186
+ config["reconnect_delay_seconds"] = float(
1187
+ config["reconnect_delay_seconds"]
1188
+ )
1189
+ config["login_timeout_seconds"] = float(
1190
+ config["login_timeout_seconds"]
1191
+ )
1192
+ config["status_poll_seconds"] = float(
1193
+ config["status_poll_seconds"]
1194
+ )
1195
+ config["command_queue_size"] = int(
1196
+ config["command_queue_size"]
1197
+ )
1198
+ config["latest_command_interval_seconds"] = float(
1199
+ config["latest_command_interval_seconds"]
1200
+ )
1201
+ except (TypeError, ValueError) as ex:
1202
+ raise ValueError(
1203
+ f"{device_id} contains an invalid connection value"
1204
+ ) from ex
1205
+
1206
+ if not config["host"]:
1207
+ raise ValueError(f"{device_id} host cannot be empty")
1208
+ if not 1 <= config["port"] <= 65535:
1209
+ raise ValueError(f"{device_id} has an invalid port")
1210
+ if bool(config["username"]) != bool(config["password"]):
1211
+ raise ValueError(
1212
+ f"{device_id} needs both username and password"
1213
+ )
1214
+ if config["reconnect_delay_seconds"] <= 0:
1215
+ raise ValueError(
1216
+ f"{device_id} reconnect delay must be positive"
1217
+ )
1218
+ if config["login_timeout_seconds"] <= 0:
1219
+ raise ValueError(
1220
+ f"{device_id} login timeout must be positive"
1221
+ )
1222
+ if config["status_poll_seconds"] < 0:
1223
+ raise ValueError(
1224
+ f"{device_id} status poll cannot be negative"
1225
+ )
1226
+ if config["command_queue_size"] <= 0:
1227
+ raise ValueError(
1228
+ f"{device_id} command queue size must be positive"
1229
+ )
1230
+ if config["latest_command_interval_seconds"] < 0:
1231
+ raise ValueError(
1232
+ f"{device_id} latest command interval cannot be negative"
1233
+ )
1234
+
1235
+ used_slots.add(slot)
1236
+ used_ids.add(device_id)
1237
+ normalized_entries.append(config)
1238
+
1239
+ return {
1240
+ "connection_stagger_seconds": connection_stagger,
1241
+ entries_key: normalized_entries,
1242
+ }
1243
+
1244
+ def __init__(
1245
+ self,
1246
+ entries,
1247
+ connection_stagger,
1248
+ log,
1249
+ thread_name="AsyncTcpDeviceLoop",
1250
+ device_label="device",
1251
+ ):
1252
+ # entries 是已经由具体驱动验证和标准化的设备配置列表。
1253
+ self.entries = entries
1254
+
1255
+ # 每台设备初次连接之间的错开时间。
1256
+ self.connection_stagger = connection_stagger
1257
+
1258
+ # 日志对象由具体 MUSE 驱动注入,框架本身不导入 mojo。
1259
+ self.log = log
1260
+
1261
+ # 后台线程名称和日志中显示的设备类型名称。
1262
+ self.thread_name = thread_name
1263
+ self.device_label = device_label
1264
+
1265
+ # 只为 enabled=True 的配置创建客户端,以 slot 为键快速查找。
1266
+ self.clients = {
1267
+ item["slot"]: self.create_client(item)
1268
+ for item in entries
1269
+ if item["enabled"]
1270
+ }
1271
+
1272
+ # 最小启用 slot 作为兼容顶层路径所控制的主设备。
1273
+ self.primary_slot = min(self.clients) if self.clients else None
1274
+
1275
+ # Event Loop 和线程在 start() 后创建。
1276
+ self.loop = None
1277
+ self.thread = None
1278
+ self._tasks = []
1279
+ self._stop_requested = False
1280
+
1281
+ @abstractmethod
1282
+ def create_client(self, config):
1283
+ """根据一条标准化配置创建具体客户端;子类必须实现。"""
1284
+
1285
+ def start(self):
1286
+ """启动唯一的后台线程;重复调用不会重复启动。"""
1287
+
1288
+ # 没有启用设备时不浪费一个线程和 Event Loop。
1289
+ if not self.clients:
1290
+ self.log.warn(f"No enabled {self.device_label} devices are configured")
1291
+ return
1292
+
1293
+ # 已经启动并仍然存活时直接返回,保证 start() 幂等。
1294
+ if self.thread and self.thread.is_alive():
1295
+ return
1296
+
1297
+ self._stop_requested = False
1298
+ for client in self.clients.values():
1299
+ client.running = True
1300
+
1301
+ # 一个管理器只创建一个 daemon 后台线程。
1302
+ self.thread = threading.Thread(
1303
+ target=self._thread_main,
1304
+ name=self.thread_name,
1305
+ daemon=True,
1306
+ )
1307
+
1308
+ # 真正执行 _thread_main,并在那里创建共享 Event Loop。
1309
+ self.thread.start()
1310
+
1311
+ def _thread_main(self):
1312
+ """后台线程入口:创建 Event Loop,并为每台设备启动连接任务。"""
1313
+
1314
+ # 为当前后台线程创建独立的 asyncio Event Loop。
1315
+ self.loop = asyncio.new_event_loop()
1316
+ asyncio.set_event_loop(self.loop)
1317
+
1318
+ # 记录该 Event Loop 将管理多少台启用设备。
1319
+ self.log.info(
1320
+ f"{self.device_label} shared asyncio loop started for "
1321
+ f"{len(self.clients)} devices"
1322
+ )
1323
+
1324
+ try:
1325
+ # 每台设备只创建一个长期运行的 connect_loop 主任务。
1326
+ for position, client in enumerate(self.clients.values()):
1327
+ if self._stop_requested:
1328
+ break
1329
+ # 按设备顺序计算首次连接延时,削平大量连接的瞬时负载。
1330
+ initial_delay = position * self.connection_stagger
1331
+ self._tasks.append(
1332
+ self.loop.create_task(client.connect_loop(initial_delay))
1333
+ )
1334
+
1335
+ # 让 Event Loop 永久运行,持续服务所有设备协程。
1336
+ self.loop.run_forever()
1337
+ finally:
1338
+ # 热重载或显式 stop() 时回收连接任务、socket 和 Event Loop,避免
1339
+ # 同一 Python 进程中留下旧的后台线程。
1340
+ pending = asyncio.all_tasks(self.loop)
1341
+ for task in pending:
1342
+ task.cancel()
1343
+ if pending:
1344
+ self.loop.run_until_complete(
1345
+ asyncio.gather(*pending, return_exceptions=True)
1346
+ )
1347
+ self._tasks.clear()
1348
+ self.loop.close()
1349
+ asyncio.set_event_loop(None)
1350
+ self.loop = None
1351
+
1352
+ def stop(self, timeout=5):
1353
+ """停止后台连接循环并回收 Event Loop;可安全从 MUSE 主线程调用。"""
1354
+
1355
+ self._stop_requested = True
1356
+ for client in self.clients.values():
1357
+ client.running = False
1358
+
1359
+ loop = self.loop
1360
+ if loop and loop.is_running():
1361
+ loop.call_soon_threadsafe(loop.stop)
1362
+
1363
+ if self.thread and self.thread.is_alive() and \
1364
+ threading.current_thread() is not self.thread:
1365
+ self.thread.join(timeout)
1366
+
1367
+ if self.thread and not self.thread.is_alive():
1368
+ self.thread = None
1369
+
1370
+ def get_client(self, slot):
1371
+ """通过数组槽位取得启用的设备客户端。"""
1372
+ try:
1373
+ # Automator 路径传来的 slot 可能是字符串,统一转换为整数。
1374
+ slot = int(slot)
1375
+ except (TypeError, ValueError):
1376
+ self.log.warn(f"Invalid {self.device_label} slot: {slot}")
1377
+ return None
1378
+
1379
+ # 未配置或 disabled 的设备不会出现在 clients 中。
1380
+ client = self.clients.get(slot)
1381
+ if not client:
1382
+ self.log.warn(
1383
+ f"{self.device_label} slot {slot} is not configured and enabled"
1384
+ )
1385
+ return client
1386
+
1387
+ def get_primary_client(self):
1388
+ """返回最低启用 slot 对应的兼容主设备。"""
1389
+ if self.primary_slot is None:
1390
+ self.log.warn(f"No primary {self.device_label} is available")
1391
+ return None
1392
+ return self.clients[self.primary_slot]
1393
+
1394
+ def schedule(self, client, commands):
1395
+ """从 MUSE 主线程安全地把命令提交到后台 Event Loop。"""
1396
+
1397
+ # 脚本刚启动、Event Loop 尚未就绪时拒绝命令。
1398
+ if not self.loop or not self.loop.is_running():
1399
+ self.log.warn(
1400
+ f"{client.log_name} command dropped before loop startup"
1401
+ )
1402
+ return False
1403
+
1404
+ # run_coroutine_threadsafe 是主线程跨到 asyncio 线程的安全桥梁。
1405
+ future = asyncio.run_coroutine_threadsafe(
1406
+ client.enqueue(commands),
1407
+ self.loop,
1408
+ )
1409
+
1410
+ # 异步入队完成后检查是否有未处理异常。
1411
+ future.add_done_callback(self._log_schedule_result)
1412
+ return True
1413
+
1414
+ def schedule_latest(self, client, key, commands):
1415
+ """跨线程提交可合并命令,同一 key 只保留队列中的最新值。"""
1416
+ if not self.loop or not self.loop.is_running():
1417
+ self.log.warn(
1418
+ f"{client.log_name} command dropped before loop startup"
1419
+ )
1420
+ return False
1421
+
1422
+ future = asyncio.run_coroutine_threadsafe(
1423
+ client.enqueue_latest(key, commands),
1424
+ self.loop,
1425
+ )
1426
+ future.add_done_callback(self._log_schedule_result)
1427
+ return True
1428
+
1429
+ def _log_schedule_result(self, future):
1430
+ """读取跨线程 Future 的结果并记录调度异常。"""
1431
+ try:
1432
+ future.result()
1433
+ except Exception as ex:
1434
+ self.log.warn(f"{self.device_label} command scheduling error: {ex}")
1435
+
1436
+ def online_state_changed(self, client):
1437
+ """在线状态发布钩子,由具体 MUSE 管理器按需要覆盖。"""
1438
+
1439
+ def query_all(self):
1440
+ """向所有启用设备分别提交状态查询。"""
1441
+ for client in self.clients.values():
1442
+ client.query_status()