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.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- 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,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,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")
|
labctl/actuators/base.py
ADDED
|
@@ -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)
|
labctl/actuators/mock.py
ADDED
|
@@ -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)
|