embedded-lab-control 0.2.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.
Files changed (64) hide show
  1. embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
  2. embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
  3. embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
  4. embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
  5. embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
  7. labctl/__init__.py +8 -0
  8. labctl/actuators/__init__.py +56 -0
  9. labctl/actuators/base.py +183 -0
  10. labctl/actuators/lcus1.py +232 -0
  11. labctl/actuators/mock.py +81 -0
  12. labctl/actuators/runtime.py +1178 -0
  13. labctl/cli.py +7485 -0
  14. labctl/core/__init__.py +36 -0
  15. labctl/core/admin_files.py +388 -0
  16. labctl/core/audit.py +269 -0
  17. labctl/core/claim_keepalive.py +342 -0
  18. labctl/core/config.py +1068 -0
  19. labctl/core/database.py +880 -0
  20. labctl/core/images.py +503 -0
  21. labctl/core/manager.py +2815 -0
  22. labctl/core/models.py +815 -0
  23. labctl/core/safe_open.py +109 -0
  24. labctl/extras.py +128 -0
  25. labctl/health/__init__.py +43 -0
  26. labctl/health/alerts.py +275 -0
  27. labctl/health/checks.py +533 -0
  28. labctl/health/daemon.py +397 -0
  29. labctl/mcp_server.py +5254 -0
  30. labctl/power/__init__.py +13 -0
  31. labctl/power/base.py +145 -0
  32. labctl/power/kasa.py +446 -0
  33. labctl/power/shelly.py +89 -0
  34. labctl/power/tasmota.py +95 -0
  35. labctl/sdwire/__init__.py +10 -0
  36. labctl/sdwire/controller.py +1116 -0
  37. labctl/sdwire/power_guard.py +190 -0
  38. labctl/serial/__init__.py +18 -0
  39. labctl/serial/boot_test.py +342 -0
  40. labctl/serial/capture.py +430 -0
  41. labctl/serial/keys.py +89 -0
  42. labctl/serial/proxy.py +802 -0
  43. labctl/serial/ser2net.py +338 -0
  44. labctl/serial/udev.py +273 -0
  45. labctl/services.py +210 -0
  46. labctl/web/__init__.py +9 -0
  47. labctl/web/activity_broadcaster.py +204 -0
  48. labctl/web/api.py +1003 -0
  49. labctl/web/app.py +202 -0
  50. labctl/web/auth.py +97 -0
  51. labctl/web/claims.py +232 -0
  52. labctl/web/static/css/style.css +477 -0
  53. labctl/web/static/js/app.js +76 -0
  54. labctl/web/templates/activity.html +202 -0
  55. labctl/web/templates/base.html +49 -0
  56. labctl/web/templates/claim_refused.html +38 -0
  57. labctl/web/templates/console.html +237 -0
  58. labctl/web/templates/dashboard.html +100 -0
  59. labctl/web/templates/login.html +25 -0
  60. labctl/web/templates/sbc_detail.html +306 -0
  61. labctl/web/templates/settings.html +78 -0
  62. labctl/web/templates/status_history.html +65 -0
  63. labctl/web/views.py +496 -0
  64. labctl/web/websocket.py +208 -0
@@ -0,0 +1,64 @@
1
+ embedded_lab_control-0.2.0.dist-info/licenses/LICENSE,sha256=InonMyQosEL1YAxd1TuJEd8UYdkXvlijEM73AL8Yujs,1066
2
+ labctl/__init__.py,sha256=lkipuZBf71IlMAUSWvssK5yZ7D4g-bHXAubjdm3l2lk,213
3
+ labctl/cli.py,sha256=cbg2oA8aQNDsXq0SWW59gDo1QTS16wryC-oUqx2AuxU,245038
4
+ labctl/extras.py,sha256=zmGSob-AIhAvJ7GWDRT86ZySBXYn3c87LkMJ4yNMQIs,4806
5
+ labctl/mcp_server.py,sha256=Ua_OCQ22JZLPVsKiBufY53aKcfS8W4kGyEKGW2VUG28,189991
6
+ labctl/services.py,sha256=NSGPffs6TOw4l4-chPHRj8SUUNahRnldvxJDQLhKu20,6415
7
+ labctl/actuators/__init__.py,sha256=dm-WGfXSrh7J-RAhC62Z7Zc8ZZi-59Kl308EBnlX2gs,1609
8
+ labctl/actuators/base.py,sha256=16Fl_nhmqE2FdfO78bzYeG8234DZObwtq17xbjqtcX4,6600
9
+ labctl/actuators/lcus1.py,sha256=mcaHCOcbCj3J_-sdL2mL3JB5GZihPx7cq0l-s1h5lX8,8516
10
+ labctl/actuators/mock.py,sha256=PWypSYrmzARgDwd-SlmePsDs2aBZlyo-XgBQcJ6EcBM,2728
11
+ labctl/actuators/runtime.py,sha256=1VPKMQybvmhteSHgq06KtSr37Y_tIOQRtuOU5DYCki4,41556
12
+ labctl/core/__init__.py,sha256=yGq4oYFaFzgd4nhgnhHpitFTO_g__eWw2JDy0NU8nYI,684
13
+ labctl/core/admin_files.py,sha256=s2ZXrqNFSRHRq8LZxyF8aP1uP2bTFyRVtcM2n3Ix5Cw,14401
14
+ labctl/core/audit.py,sha256=xSts3q2ANI9P7vqggstub65n5h35pNYDhQVcY2qTIc4,8129
15
+ labctl/core/claim_keepalive.py,sha256=PGaVniI1bAXOfqVweQYZPrL5hZ4TnM9y164sLPHSpqI,13387
16
+ labctl/core/config.py,sha256=9EIcBX70ZJ9xdgOHN4S_8d4aMAiSMWfPIyHuzL-bFXU,39558
17
+ labctl/core/database.py,sha256=eVOWzR-urzWQViGZM9kCbKgxFnTLhnf5lES_bmbyFMs,37136
18
+ labctl/core/images.py,sha256=Yg9cUHtAj0JMrdIbk8Va94rr6ozVI9oCjzwog2r_w4I,19018
19
+ labctl/core/manager.py,sha256=72mHaUDuHjr5b7FRWYmaQVsJgtTxL04W1SiuHEr0RGk,104260
20
+ labctl/core/models.py,sha256=iO7wWWGd0Bx3WzUlURSLXC24yB82FEfUZNBVx1xrCAg,25929
21
+ labctl/core/safe_open.py,sha256=nzUTgLykOheT6uZOESRlTSx2THImr-Kpa7V3q0tfUyA,4234
22
+ labctl/health/__init__.py,sha256=eUgwHUzufBAlzWN0mDQOG6eyJGzLoPC8jRJ3hwIOYnY,845
23
+ labctl/health/alerts.py,sha256=lQRqr8CvVuh0H0nHMnwHwrFliJPKKdFu4lhBx5ikNdo,7828
24
+ labctl/health/checks.py,sha256=t3dYE4EucG27-N7-yYIQZ3F7H0yf2_Jchzn_xQaGfdg,18551
25
+ labctl/health/daemon.py,sha256=ZgxibyRSZz1MYCkwlRIOQX_OgglV00LjqH0ESjl2TeY,14948
26
+ labctl/power/__init__.py,sha256=-9eud2EnabNgd-ZC_WlWwUJvVGRJ_7oKc37-8g_AqnM,270
27
+ labctl/power/base.py,sha256=HqiyE9LDEEqf7cLqpt32vVRBemnhltYoULKlJIfDHhU,3531
28
+ labctl/power/kasa.py,sha256=pr48q75JZmINI-3PbRXTpEVufM23EwrrNHv3z5gOuZw,17312
29
+ labctl/power/shelly.py,sha256=s30-SkQxc9SLhbdiNehrpGOAwMHtZJCd4aSImHTdo6Q,2424
30
+ labctl/power/tasmota.py,sha256=XXlTrvqTXcPlSpRJ0LLkn7R96oyfNcNoYrz0COc6n94,2556
31
+ labctl/sdwire/__init__.py,sha256=oCgO5CJmIGibCDmsy40EYDicruw-c3QuE5hF4O8UJlY,267
32
+ labctl/sdwire/controller.py,sha256=7WiBgZlpjAkXYgdypqvQOj6M9g7netzqgqXoRXDxKgY,40052
33
+ labctl/sdwire/power_guard.py,sha256=ZQyUCYflCjPvnFtVmpHBPG7zYWcJZOJZnE3Ot8_Ty1g,7035
34
+ labctl/serial/__init__.py,sha256=0nnecD67zY9oUuhBc8JED8R0rRjPTtE9m3BS_9bsGW0,453
35
+ labctl/serial/boot_test.py,sha256=FIp7ToSqQzsCEIAdb0J-A85lHvJCL76D3cONAGcsShY,11564
36
+ labctl/serial/capture.py,sha256=gCmpFkTGpVggQnLCOYqNpDEISVdp80j1K4tvMVXVLgU,13242
37
+ labctl/serial/keys.py,sha256=p-US8NPeuNncSp8hS3WfK13h0GAIy2nHZp5HVIH1PvE,2739
38
+ labctl/serial/proxy.py,sha256=MYYL-Odwv7LtcdjN2ByPsE2FaKpKeFb_ZnGifwKusUU,27266
39
+ labctl/serial/ser2net.py,sha256=ojyayulb7VI-cDgmj2MQhaBAPzYR23tN9Zp4zD9afmo,11246
40
+ labctl/serial/udev.py,sha256=CeCW0sPUvdUIEC2P2TgMos-2nLQJAN6UjDBRr7Bf7No,9204
41
+ labctl/web/__init__.py,sha256=pPEZvBgIr0gpCfB372nVoVBN7cljJhOh6HmRxhSAP_I,172
42
+ labctl/web/activity_broadcaster.py,sha256=MF3ojS5_FIpuwT5S-efxSCq6kzU_8rJNULY1rPAXHUk,6940
43
+ labctl/web/api.py,sha256=sqczdaxOg8SYvKh2BbdqarMfNXemf73-ziR4K8Ipi9Y,32224
44
+ labctl/web/app.py,sha256=A84_WruJ8tcZtO8bN7G2hjB4IH-bbmMon_ZQ4Xtn6Lg,6855
45
+ labctl/web/auth.py,sha256=Ti0FpunkBENy9WVgmR1VJvFJumLeovLjixdFh0sQMZY,2899
46
+ labctl/web/claims.py,sha256=D7HEiRfV0J8J0B4z7bIHzyoF0S3od5RSTxWaW_0DBOA,8214
47
+ labctl/web/views.py,sha256=FZNGVOE39yof0CcZTuiR5FTzVNGMfN-L4AjKDdntL0w,15701
48
+ labctl/web/websocket.py,sha256=gm7U__qovW-vYa8B-GQDjI8lnCMVoeAVE_p7W3tqN5E,6417
49
+ labctl/web/static/css/style.css,sha256=87irVk8NiJ-NQyoI9nAO1a-Ld51J0DmJUnnKnjPe6Y0,8825
50
+ labctl/web/static/js/app.js,sha256=W8pXlJNO0Ln-Oi21TU04zfaYHeEpRv4S2vaLsL2hRAg,1894
51
+ labctl/web/templates/activity.html,sha256=wctjzjJRBOuhmrIgCeDtRwfiRNJ6f7-Zn0mV0ljhqtA,8967
52
+ labctl/web/templates/base.html,sha256=cG8XrbuZmalPcLMvJNGBP99mcH4fOPaq93piJk21v_0,1918
53
+ labctl/web/templates/claim_refused.html,sha256=xxZeGDY6Zvf_J_y1f6wrHM0EmJjzEUObB5I6POlBJk8,1557
54
+ labctl/web/templates/console.html,sha256=rYRTuYLqGZ3gXRCIkDdl3bvpQQ-Betis2lW1hGFlmM0,7226
55
+ labctl/web/templates/dashboard.html,sha256=PVdwfjF--QY7I3bZVKcvgEyjWLW2v1nWkl7i6zzgvaU,4377
56
+ labctl/web/templates/login.html,sha256=nT4BNddBzJOtQ3EE6sFl-ET2p67rxJXUdEPJsdR2CQs,938
57
+ labctl/web/templates/sbc_detail.html,sha256=dWMIFwq_-qVRz4_4OG5fVEV9dtCln3lz9tPNox3j8YY,14741
58
+ labctl/web/templates/settings.html,sha256=duYgyoFmA0eYb1h263RWQKu8_cOOQf6E6CeWGIvoi9k,3764
59
+ labctl/web/templates/status_history.html,sha256=l034wodL8mov9TA0KDo3NyD8s3vi4nenHzNEfod3ve8,1757
60
+ embedded_lab_control-0.2.0.dist-info/METADATA,sha256=ktNFWfoVAhXdMqIt2q0Zan0HS10SMF5Uc7-V9yFxsFI,40839
61
+ embedded_lab_control-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
62
+ embedded_lab_control-0.2.0.dist-info/entry_points.txt,sha256=ZhZdQifWTGrL_6F6oq4MFWmgLsfjJ1zpJfpWyoMWsik,43
63
+ embedded_lab_control-0.2.0.dist-info/top_level.txt,sha256=hNHhKmS2AGBs1uJ5U9XASup4_iYWtzqKfBJXqBM91vw,7
64
+ embedded_lab_control-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ labctl = labctl.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Jezl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ labctl
labctl/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Embedded Lab Control (labctl).
3
+
4
+ Centralized management system for embedded development lab resources.
5
+ Provides deterministic USB serial access, power control, and resource tracking.
6
+ """
7
+
8
+ __version__ = "0.2.0"
@@ -0,0 +1,56 @@
1
+ """Actuator drivers and runtime layer.
2
+
3
+ The :class:`RelayDriver` ABC defines the small protocol every concrete
4
+ driver implements (LCUS-1, Numato, etc.). The runtime layer (added in
5
+ later phases) sits between the manager's persistent state and the
6
+ driver's transport; this package is the driver-side boundary.
7
+
8
+ Use :func:`get_driver` rather than importing concrete driver modules
9
+ directly so deferred drivers aren't paid for at import time.
10
+ """
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from labctl.actuators.base import (
15
+ DriverError,
16
+ ProbeOutcome,
17
+ ProbeResult,
18
+ RelayDriver,
19
+ Transport,
20
+ WriteOutcome,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from labctl.core.models import DriverName
25
+
26
+ __all__ = [
27
+ "DriverError",
28
+ "ProbeOutcome",
29
+ "ProbeResult",
30
+ "RelayDriver",
31
+ "Transport",
32
+ "WriteOutcome",
33
+ "get_driver",
34
+ ]
35
+
36
+
37
+ def get_driver(
38
+ driver_name: "DriverName",
39
+ *,
40
+ expected_channel_count: int | None = None,
41
+ ) -> RelayDriver:
42
+ """Return a fresh, unopened driver instance for ``driver_name``.
43
+
44
+ The returned driver still needs an :meth:`~RelayDriver.open` call
45
+ with a :class:`Transport` before any I/O.
46
+
47
+ Drivers other than ``LCUS1_SERIAL`` are placeholders in v1 and
48
+ raise :class:`NotImplementedError` until their implementations land.
49
+ """
50
+ from labctl.core.models import DriverName
51
+
52
+ if driver_name is DriverName.LCUS1_SERIAL:
53
+ from labctl.actuators.lcus1 import Lcus1SerialDriver
54
+
55
+ return Lcus1SerialDriver(expected_channel_count=expected_channel_count)
56
+ raise NotImplementedError(f"driver {driver_name.value!r} is not implemented yet")
@@ -0,0 +1,183 @@
1
+ """Base classes for relay/actuator drivers.
2
+
3
+ Every concrete driver (LCUS-1, Numato, HID, sysfs-GPIO, …) implements
4
+ :class:`RelayDriver`. The runtime layer never imports a concrete
5
+ driver directly — it asks the registry for a driver matching the
6
+ :class:`labctl.core.models.DriverName` recorded on the actuator row.
7
+
8
+ Outcome enums (:class:`WriteOutcome`, :class:`ProbeResult`) are the
9
+ contract that lets composite operations like ``enter_recovery`` make
10
+ safe decisions when the underlying USB transport flakes mid-sequence
11
+ — see ``docs/SPEC_actuators.md`` ("USB disconnect mid-composite").
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from abc import ABC, abstractmethod
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+ from typing import Optional
21
+
22
+
23
+ class WriteOutcome(Enum):
24
+ """Structured result for a single :meth:`RelayDriver.set_channel` call."""
25
+
26
+ OK = "ok" # write completed and was acknowledged (or believed-acked)
27
+ WRITE_FAILED = "write_failed" # device responded but rejected the command
28
+ DEVICE_GONE = "device_gone" # USB disconnect / port closed mid-write
29
+
30
+
31
+ class ProbeResult(Enum):
32
+ """Coarse health classification returned by :meth:`RelayDriver.probe`."""
33
+
34
+ OK = "ok"
35
+ UNREACHABLE = "unreachable" # cannot open the transport at all
36
+ AUTH_ERROR = "auth_error" # placeholder for driver families that need it
37
+ UNKNOWN = "unknown"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ProbeOutcome:
42
+ """Read-only health probe response.
43
+
44
+ Probes are explicitly **side-effect-free** — they MUST NOT toggle
45
+ a channel even on write-only hardware. This avoids drops of held
46
+ pre-power straps mid-flash. See SPEC_actuators.md §"Health probes".
47
+ """
48
+
49
+ result: ProbeResult
50
+ detail: Optional[str] = None # human-readable extra info
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Transport:
55
+ """Connection parameters passed to :meth:`RelayDriver.open`.
56
+
57
+ Only the fields a given driver needs are populated; extras are
58
+ ignored. Captured as a dataclass so future drivers can grow new
59
+ fields without breaking the ABC signature.
60
+ """
61
+
62
+ device_path: Optional[str] = None
63
+ vid: Optional[str] = None
64
+ pid: Optional[str] = None
65
+ serial_no: Optional[str] = None
66
+ baud: int = 9600
67
+ open_timeout_seconds: float = 2.0
68
+
69
+
70
+ def device_permission_hint(device_path: str) -> str:
71
+ """What to do when this process may not open ``device_path``.
72
+
73
+ Recommends joining the device's group only when that group grants
74
+ read/write and isn't root's; says when the account is already in it
75
+ but this process isn't (a login or service restart is missing).
76
+ """
77
+ import grp
78
+ import pwd
79
+ import stat
80
+
81
+ try:
82
+ account = pwd.getpwuid(os.geteuid())
83
+ user, primary_gid = account.pw_name, account.pw_gid
84
+ except KeyError:
85
+ user, primary_gid = None, None # no account to name in usermod
86
+ generic = (
87
+ f"check that {user or f'uid {os.geteuid()}'} may read and write "
88
+ f"{device_path} "
89
+ "(ls -lL shows its owner, group and mode; for USB serial relays a "
90
+ "udev rule normally gives the dialout group read/write)"
91
+ )
92
+ try:
93
+ st = os.stat(device_path)
94
+ group = grp.getgrgid(st.st_gid)
95
+ except (OSError, KeyError):
96
+ return generic
97
+ group_rw = stat.S_IRGRP | stat.S_IWGRP
98
+ if user is None or st.st_gid == 0 or st.st_mode & group_rw != group_rw:
99
+ return generic
100
+ name = group.gr_name
101
+ if st.st_gid in os.getgroups() or st.st_gid == os.getegid():
102
+ return (
103
+ f"this process is already in the '{name}' group that owns it, so "
104
+ f"something else denies access; {generic}"
105
+ )
106
+ takes_effect = (
107
+ f"it takes effect in processes started after that: log out and back "
108
+ f"in (or run newgrp {name} in this shell), and restart any service "
109
+ "or MCP client that runs labctl"
110
+ )
111
+ if user in group.gr_mem or primary_gid == st.st_gid:
112
+ return (
113
+ f"{user} is in the '{name}' group that owns the device, but this "
114
+ f"process started before it was added; {takes_effect}"
115
+ )
116
+ return (
117
+ f"{user} needs to be in the '{name}' group that owns the device "
118
+ f"(check with: id -nG). To add it: sudo usermod -aG {name} {user}; "
119
+ + takes_effect
120
+ )
121
+
122
+
123
+ class DriverError(Exception):
124
+ """Raised by drivers for unrecoverable errors (bad config, etc.).
125
+
126
+ Transient I/O issues should be reported as :class:`WriteOutcome`
127
+ or :class:`ProbeOutcome` instead, not raised — the runtime relies
128
+ on the structured outcome to decide whether to abort a composite.
129
+ """
130
+
131
+
132
+ class RelayDriver(ABC):
133
+ """Minimal contract every concrete actuator driver implements.
134
+
135
+ Lifecycle:
136
+ 1. Construct (cheap, no I/O).
137
+ 2. :meth:`open` once with a :class:`Transport`.
138
+ 3. Any number of :meth:`set_channel` / :meth:`get_channel` /
139
+ :meth:`probe` calls.
140
+ 4. :meth:`close` to release the transport.
141
+
142
+ Concrete drivers MUST be safe to instantiate without an event loop
143
+ or any I/O so the manager can construct them lazily during config
144
+ loading. All I/O happens inside :meth:`open` and later calls.
145
+ """
146
+
147
+ @abstractmethod
148
+ def open(self, transport: Transport) -> None:
149
+ """Acquire the transport. Idempotent if already open."""
150
+
151
+ @abstractmethod
152
+ def close(self) -> None:
153
+ """Release the transport. Idempotent on a closed driver."""
154
+
155
+ @abstractmethod
156
+ def set_channel(self, index: int, *, closed: bool) -> WriteOutcome:
157
+ """Drive a channel to ``closed`` (True) or ``open`` (False).
158
+
159
+ Returns a :class:`WriteOutcome`. Drivers SHOULD NOT raise on
160
+ I/O errors — return ``DEVICE_GONE`` or ``WRITE_FAILED`` instead
161
+ so callers can make composite-safe decisions.
162
+ """
163
+
164
+ @abstractmethod
165
+ def get_channel(self, index: int) -> Optional[bool]:
166
+ """Return ``True`` if closed, ``False`` if open, ``None`` if not queryable.
167
+
168
+ LCUS-1 returns ``None`` here; Numato can read back actual state.
169
+ """
170
+
171
+ @abstractmethod
172
+ def channel_count(self) -> Optional[int]:
173
+ """Return the device's channel count, or ``None`` if not enumerable.
174
+
175
+ For non-enumerable drivers (LCUS-1), the manager's provisioning
176
+ ``--channels`` hint is authoritative. For enumerable drivers
177
+ (Numato), this overrides the hint and a mismatch is rejected
178
+ at provisioning time.
179
+ """
180
+
181
+ @abstractmethod
182
+ def probe(self) -> ProbeOutcome:
183
+ """Read-only health check. MUST NOT toggle a channel."""
@@ -0,0 +1,232 @@
1
+ """LCUS-1 (CH340-based USB relay) driver.
2
+
3
+ Hardware notes
4
+ ==============
5
+ LCUS-1 boards are the cheap CH340-clone single-channel USB relays
6
+ (also branded "USR-IO" or unbranded). They speak a 4-byte fixed-frame
7
+ protocol at 9600/8N1; there is no acknowledgement and no state
8
+ read-back. Relay state must be tracked in software.
9
+
10
+ Frame layout (no spaces in the wire bytes):
11
+
12
+ AA CC SS KK
13
+ │ │ │ └─ checksum: low byte of (AA + CC + SS)
14
+ │ │ └──── state: 0x00 = open, 0x01 = close
15
+ │ └──────── channel index, 1-based (typically 0x01)
16
+ └──────────── start byte 0xA0
17
+
18
+ Examples:
19
+
20
+ A0 01 01 A2 → channel 1 close
21
+ A0 01 00 A1 → channel 1 open
22
+ A0 02 01 A3 → channel 2 close (multi-channel clones)
23
+
24
+ Probe semantics
25
+ ---------------
26
+ We deliberately do NOT toggle a channel during ``probe`` — see
27
+ ``docs/SPEC_actuators.md`` §"Health probes". A held ``recovery_mode``
28
+ strap mid-flash must survive a probe cycle. Probe = "open the serial
29
+ port, verify it opens, close" — that's it.
30
+
31
+ Concurrency
32
+ -----------
33
+ The driver itself is not thread-safe across multiple ``set_channel``
34
+ calls; the manager-level per-channel lock (added in Phase 4) is what
35
+ serializes writes. ``open`` and ``close`` are idempotent.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import errno
41
+ import logging
42
+ from typing import Optional
43
+
44
+ from labctl.actuators.base import (
45
+ DriverError,
46
+ ProbeOutcome,
47
+ ProbeResult,
48
+ RelayDriver,
49
+ Transport,
50
+ WriteOutcome,
51
+ device_permission_hint,
52
+ )
53
+ from labctl.extras import broken_extra_message, is_missing, missing_extra_message
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ # Frame layout constants. Documented inline in the module docstring above.
59
+ LCUS1_START_BYTE = 0xA0
60
+ LCUS1_STATE_CLOSED = 0x01
61
+ LCUS1_STATE_OPEN = 0x00
62
+ LCUS1_FRAME_LEN = 4
63
+
64
+
65
+ def _build_frame(channel_index: int, *, closed: bool) -> bytes:
66
+ """Build a 4-byte LCUS-1 command frame."""
67
+ state = LCUS1_STATE_CLOSED if closed else LCUS1_STATE_OPEN
68
+ checksum = (LCUS1_START_BYTE + channel_index + state) & 0xFF
69
+ return bytes([LCUS1_START_BYTE, channel_index, state, checksum])
70
+
71
+
72
+ class Lcus1SerialDriver(RelayDriver):
73
+ """LCUS-1 / generic CH340 single-channel USB relay driver."""
74
+
75
+ def __init__(self, *, expected_channel_count: Optional[int] = None):
76
+ """Construct the driver. Hardware I/O happens in :meth:`open`.
77
+
78
+ Args:
79
+ expected_channel_count: hint from provisioning. LCUS-1 has
80
+ no enumeration protocol, so this value is what
81
+ :meth:`channel_count` returns. The manager treats this
82
+ as authoritative for non-enumerable drivers.
83
+ """
84
+ self._port = None # type: Optional[object] # serial.Serial
85
+ self._transport: Optional[Transport] = None
86
+ self._expected_channels = expected_channel_count
87
+
88
+ # -- lifecycle ---------------------------------------------------------
89
+
90
+ def open(self, transport: Transport) -> None:
91
+ """Open the serial port. Idempotent if already open."""
92
+ if self._port is not None:
93
+ return
94
+ try:
95
+ import serial # type: ignore
96
+ except ImportError as e:
97
+ if not is_missing(e, "serial"):
98
+ raise DriverError(
99
+ f"lcus1_serial driver: {broken_extra_message('actuators', e)}"
100
+ ) from e
101
+ raise DriverError(
102
+ f"lcus1_serial driver: {missing_extra_message('actuators')}"
103
+ ) from e
104
+
105
+ if not transport.device_path:
106
+ raise DriverError("lcus1_serial driver requires transport.device_path")
107
+
108
+ try:
109
+ self._port = serial.Serial(
110
+ port=transport.device_path,
111
+ baudrate=transport.baud or 9600,
112
+ bytesize=8,
113
+ parity=serial.PARITY_NONE,
114
+ stopbits=1,
115
+ timeout=transport.open_timeout_seconds,
116
+ write_timeout=transport.open_timeout_seconds,
117
+ )
118
+ except (OSError, serial.SerialException) as e:
119
+ self._port = None
120
+ if getattr(e, "errno", None) in (errno.EACCES, errno.EPERM):
121
+ raise DriverError(
122
+ f"permission denied opening {transport.device_path}: "
123
+ + device_permission_hint(transport.device_path)
124
+ ) from e
125
+ raise DriverError(f"failed to open {transport.device_path}: {e}") from e
126
+ self._transport = transport
127
+ logger.debug("LCUS-1 opened %s", transport.device_path)
128
+
129
+ def close(self) -> None:
130
+ """Close the serial port. Idempotent on a closed driver."""
131
+ if self._port is None:
132
+ return
133
+ try:
134
+ self._port.close()
135
+ except Exception as e: # noqa: BLE001
136
+ logger.debug("LCUS-1 close raised %s; ignoring", e)
137
+ finally:
138
+ self._port = None
139
+
140
+ def _require_open(self) -> None:
141
+ if self._port is None:
142
+ raise DriverError("driver not open; call open(transport) first")
143
+
144
+ # -- I/O ---------------------------------------------------------------
145
+
146
+ def set_channel(self, index: int, *, closed: bool) -> WriteOutcome:
147
+ """Drive a channel; return a structured outcome.
148
+
149
+ Returns ``DEVICE_GONE`` for transport-level failures (USB
150
+ disconnect, port closed) and ``WRITE_FAILED`` for short writes
151
+ or other recoverable error paths.
152
+ """
153
+ self._require_open()
154
+ if index < 1:
155
+ return WriteOutcome.WRITE_FAILED
156
+ if self._expected_channels is not None and index > self._expected_channels:
157
+ return WriteOutcome.WRITE_FAILED
158
+
159
+ frame = _build_frame(index, closed=closed)
160
+ try:
161
+ written = self._port.write(frame)
162
+ self._port.flush()
163
+ except Exception as e: # noqa: BLE001 — pyserial raises a zoo of these
164
+ # Any transport-level failure is reported as DEVICE_GONE so
165
+ # composite operations abort safely instead of retrying onto
166
+ # a vanished device.
167
+ logger.warning(
168
+ "LCUS-1 write to channel %d failed: %s: %s",
169
+ index,
170
+ type(e).__name__,
171
+ e,
172
+ )
173
+ return WriteOutcome.DEVICE_GONE
174
+
175
+ if written != LCUS1_FRAME_LEN:
176
+ logger.warning(
177
+ "LCUS-1 short write to channel %d: %d/%d bytes",
178
+ index,
179
+ written,
180
+ LCUS1_FRAME_LEN,
181
+ )
182
+ return WriteOutcome.WRITE_FAILED
183
+ return WriteOutcome.OK
184
+
185
+ def get_channel(self, index: int) -> Optional[bool]:
186
+ """LCUS-1 has no read-back; always returns None."""
187
+ return None
188
+
189
+ def channel_count(self) -> Optional[int]:
190
+ """No enumeration protocol — return the provisioning hint."""
191
+ return self._expected_channels
192
+
193
+ # -- probe -------------------------------------------------------------
194
+
195
+ def probe(self) -> ProbeOutcome:
196
+ """Read-only probe: try opening the port if not already open.
197
+
198
+ MUST NOT toggle a channel even on write-only hardware — a held
199
+ pre_power strap during a flash must survive probes.
200
+ """
201
+ if self._port is not None:
202
+ return ProbeOutcome(result=ProbeResult.OK)
203
+
204
+ # Opening transiently to verify reachability without writing.
205
+ if self._transport is None:
206
+ return ProbeOutcome(
207
+ result=ProbeResult.UNKNOWN,
208
+ detail="probe before first open() — no transport hint",
209
+ )
210
+
211
+ try:
212
+ import serial # type: ignore
213
+ except ImportError as e:
214
+ if not is_missing(e, "serial"):
215
+ return ProbeOutcome(
216
+ result=ProbeResult.UNKNOWN,
217
+ detail=broken_extra_message("actuators", e),
218
+ )
219
+ return ProbeOutcome(
220
+ result=ProbeResult.UNKNOWN, detail=missing_extra_message("actuators")
221
+ )
222
+
223
+ try:
224
+ test = serial.Serial(
225
+ port=self._transport.device_path,
226
+ baudrate=self._transport.baud or 9600,
227
+ timeout=self._transport.open_timeout_seconds,
228
+ )
229
+ test.close()
230
+ except (OSError, serial.SerialException) as e:
231
+ return ProbeOutcome(result=ProbeResult.UNREACHABLE, detail=str(e))
232
+ return ProbeOutcome(result=ProbeResult.OK)
@@ -0,0 +1,81 @@
1
+ """In-memory mock driver for tests.
2
+
3
+ Programmable failure modes let tests exercise the
4
+ :class:`~labctl.actuators.base.WriteOutcome` paths (composite abort
5
+ on ``DEVICE_GONE``, etc.) without requiring real hardware.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from labctl.actuators.base import (
13
+ DriverError,
14
+ ProbeOutcome,
15
+ ProbeResult,
16
+ RelayDriver,
17
+ Transport,
18
+ WriteOutcome,
19
+ )
20
+
21
+
22
+ class MockRelayDriver(RelayDriver):
23
+ """Deterministic, in-memory relay used by tests and dry-runs.
24
+
25
+ State for each channel is kept in a dict; reads return the cached
26
+ value (so :meth:`get_channel` returns the truth post-write).
27
+
28
+ Failure-mode toggles:
29
+ * ``next_write_outcome`` — if set, the next ``set_channel`` call
30
+ returns this outcome and clears the override. Useful for
31
+ driving composite-abort paths.
32
+ * ``probe_result`` — what :meth:`probe` returns. Default OK.
33
+ * ``queryable`` — when False, :meth:`get_channel` returns None
34
+ (LCUS-1-like behavior).
35
+ """
36
+
37
+ def __init__(self, *, channel_count: int = 1, queryable: bool = True):
38
+ self._configured_count = channel_count
39
+ self._queryable = queryable
40
+ self._states: dict[int, bool] = {}
41
+ self._open = False
42
+ self.next_write_outcome: Optional[WriteOutcome] = None
43
+ self.probe_result: ProbeResult = ProbeResult.OK
44
+ self.write_log: list[tuple[int, bool, WriteOutcome]] = []
45
+
46
+ def open(self, transport: Transport) -> None:
47
+ self._open = True
48
+
49
+ def close(self) -> None:
50
+ self._open = False
51
+
52
+ def _require_open(self) -> None:
53
+ if not self._open:
54
+ raise DriverError("driver not open")
55
+
56
+ def set_channel(self, index: int, *, closed: bool) -> WriteOutcome:
57
+ self._require_open()
58
+ if index < 1 or index > self._configured_count:
59
+ return WriteOutcome.WRITE_FAILED
60
+ if self.next_write_outcome is not None:
61
+ outcome = self.next_write_outcome
62
+ self.next_write_outcome = None
63
+ self.write_log.append((index, closed, outcome))
64
+ if outcome is WriteOutcome.OK:
65
+ self._states[index] = closed
66
+ return outcome
67
+ self._states[index] = closed
68
+ self.write_log.append((index, closed, WriteOutcome.OK))
69
+ return WriteOutcome.OK
70
+
71
+ def get_channel(self, index: int) -> Optional[bool]:
72
+ self._require_open()
73
+ if not self._queryable:
74
+ return None
75
+ return self._states.get(index)
76
+
77
+ def channel_count(self) -> Optional[int]:
78
+ return self._configured_count
79
+
80
+ def probe(self) -> ProbeOutcome:
81
+ return ProbeOutcome(result=self.probe_result)