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,109 @@
1
+ """
2
+ Open host paths without following symlinks, for check-then-use safety.
3
+
4
+ The MCP host-path allowlist resolves a path and checks it against allowed
5
+ directories. If the file were then re-opened *by path* later (after a
6
+ power-off, a mux switch, a wait for the reader...), anyone able to write to
7
+ a directory on that path could swap in a symlink in between and redirect
8
+ the read or write; raw images are even read by ``sudo dd`` (root). These
9
+ helpers open the already-resolved path one component at a time with
10
+ ``O_NOFOLLOW``, so a symlink anywhere on the path makes the open fail
11
+ instead of being followed. Callers then use the returned file descriptor,
12
+ never the path, for the actual I/O.
13
+ """
14
+
15
+ import errno
16
+ import fcntl
17
+ import os
18
+ import stat
19
+
20
+ # Intermediate directories need only search permission with O_PATH (Linux);
21
+ # fall back to O_RDONLY elsewhere. O_DIRECTORY|O_NOFOLLOW makes a symlinked
22
+ # component fail with ENOTDIR/ELOOP rather than be followed.
23
+ _WALK_FLAGS = getattr(os, "O_PATH", os.O_RDONLY) | os.O_DIRECTORY | os.O_NOFOLLOW
24
+
25
+
26
+ def _components(path: str) -> list[str]:
27
+ if not os.path.isabs(path):
28
+ raise ValueError(f"path must be absolute: {path!r}")
29
+ parts = [p for p in path.split("/") if p]
30
+ if any(p in (".", "..") for p in parts):
31
+ raise OSError(errno.EINVAL, "path must be normalized (no '.' or '..')")
32
+ return parts
33
+
34
+
35
+ def _walk(parts: list[str], *, create: bool, mode: int) -> int:
36
+ """Open the directory reached by ``parts`` from "/", no symlinks."""
37
+ fd = os.open("/", _WALK_FLAGS)
38
+ try:
39
+ for name in parts:
40
+ try:
41
+ nxt = os.open(name, _WALK_FLAGS, dir_fd=fd)
42
+ except FileNotFoundError:
43
+ if not create:
44
+ raise
45
+ try:
46
+ os.mkdir(name, mode, dir_fd=fd)
47
+ except FileExistsError:
48
+ pass # created concurrently; the open below re-checks it
49
+ nxt = os.open(name, _WALK_FLAGS, dir_fd=fd)
50
+ os.close(fd)
51
+ fd = nxt
52
+ except BaseException:
53
+ os.close(fd)
54
+ raise
55
+ return fd
56
+
57
+
58
+ def open_regular_nonblocking(
59
+ name: str, flags: int, mode: int = 0o644, dir_fd: int | None = None
60
+ ) -> int:
61
+ """Open ``name`` (O_NOFOLLOW), insisting it is a regular file.
62
+
63
+ Opened with O_NONBLOCK so a FIFO planted in a shared directory can't
64
+ block the open (which would hang the caller while it holds locks);
65
+ anything that isn't a regular file is closed and refused with OSError;
66
+ O_NONBLOCK is then cleared for normal I/O.
67
+ """
68
+ fd = os.open(name, flags | os.O_NOFOLLOW | os.O_NONBLOCK, mode, dir_fd=dir_fd)
69
+ try:
70
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
71
+ raise OSError(errno.EINVAL, f"not a regular file: {name}")
72
+ fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) & ~os.O_NONBLOCK)
73
+ except BaseException:
74
+ os.close(fd)
75
+ raise
76
+ return fd
77
+
78
+
79
+ def open_file_nofollow(path: str, flags: int = os.O_RDONLY, mode: int = 0o644) -> int:
80
+ """Open the regular file at absolute, normalized ``path``, no symlinks.
81
+
82
+ Raises OSError (ELOOP/ENOTDIR) if any component is a symlink, and
83
+ (EINVAL) if the target isn't a regular file (e.g. a FIFO); never blocks
84
+ on opening a FIFO.
85
+ """
86
+ parts = _components(path)
87
+ if not parts:
88
+ raise IsADirectoryError(errno.EISDIR, "path is the root directory")
89
+ parent = _walk(parts[:-1], create=False, mode=0o755)
90
+ try:
91
+ return open_regular_nonblocking(parts[-1], flags, mode, dir_fd=parent)
92
+ finally:
93
+ os.close(parent)
94
+
95
+
96
+ def open_dir_nofollow(path: str, *, create: bool = False, mode: int = 0o775) -> int:
97
+ """Open (optionally creating) directory ``path`` with no symlink anywhere.
98
+
99
+ Returns an ``O_RDONLY | O_DIRECTORY`` descriptor usable as ``dir_fd``
100
+ for creating files inside it.
101
+ """
102
+ parts = _components(path)
103
+ walked = _walk(parts, create=create, mode=mode)
104
+ try:
105
+ # Re-open "." relative to the walked handle as a regular directory
106
+ # fd (an O_PATH fd can't be used for everything on every kernel).
107
+ return os.open(".", os.O_RDONLY | os.O_DIRECTORY, dir_fd=walked)
108
+ finally:
109
+ os.close(walked)
labctl/extras.py ADDED
@@ -0,0 +1,128 @@
1
+ """Install hints for optional dependencies ("extras").
2
+
3
+ A bare ``pip install embedded-lab-control`` has only the core
4
+ dependencies; the web UI, MCP server, Kasa plugs, SDWire muxes and USB
5
+ relays each need an extra. Code that imports one of those packages
6
+ turns the ImportError into a one-line message naming the extra, built
7
+ here so every surface (CLI, REST, MCP) says the same thing.
8
+ """
9
+
10
+ import sys
11
+
12
+ DIST_NAME = "embedded-lab-control"
13
+
14
+ # extra -> what it enables. Keep in step with
15
+ # [project.optional-dependencies] in pyproject.toml; a test checks the
16
+ # names and extra_packages() against it.
17
+ EXTRAS: dict[str, str] = {
18
+ "web": "Web dashboard and REST API support",
19
+ "mcp": "MCP support",
20
+ "kasa": "Kasa power control",
21
+ "sdwire": "SDWire support",
22
+ "actuators": "USB relay support",
23
+ }
24
+
25
+ # The extras' requirements, as pyproject.toml declares them, for
26
+ # `pipx inject` (which installs packages, not extras).
27
+ _PACKAGES: dict[str, tuple[str, ...]] = {
28
+ "web": ("flask>=3.0", "flask-socketio>=5.0"),
29
+ "mcp": ("mcp>=2.2,<3",),
30
+ "sdwire": ("sdwire>=0.3",),
31
+ "actuators": ("pyserial>=3.5",),
32
+ }
33
+
34
+
35
+ class MissingExtraError(RuntimeError):
36
+ """An optional dependency is not installed.
37
+
38
+ A RuntimeError so existing ``except RuntimeError`` reporting (CLI
39
+ "Error: ...", REST/MCP error results) shows the hint unchanged.
40
+ """
41
+
42
+ def __init__(self, extra: str, what: str | None = None):
43
+ self.extra = extra
44
+ super().__init__(missing_extra_message(extra, what))
45
+
46
+
47
+ class BrokenExtraError(RuntimeError):
48
+ """An optional dependency is installed but fails to import (a
49
+ dependency of it is missing, or the version is incompatible).
50
+
51
+ A RuntimeError, like MissingExtraError, so callers report it the same
52
+ way, but with the real ImportError in the message.
53
+ """
54
+
55
+ def __init__(self, extra: str, error: ImportError, what: str | None = None):
56
+ self.extra = extra
57
+ super().__init__(broken_extra_message(extra, error, what))
58
+
59
+
60
+ def broken_extra_message(
61
+ extra: str, error: ImportError, what: str | None = None
62
+ ) -> str:
63
+ """One line for an extra whose package is installed but won't import.
64
+
65
+ No install command: reinstalling brings back the same newest version
66
+ when that version is the incompatible one, and the right command
67
+ depends on how labctl was installed (pipx, a venv, a git checkout).
68
+ """
69
+ what = what or EXTRAS[extra]
70
+ packages = ", ".join(extra_packages(extra))
71
+ return (
72
+ f"{what} is installed but failed to import ({error}): a dependency "
73
+ f"is missing or the installed version is incompatible (the [{extra}] "
74
+ f"extra wants {packages})"
75
+ )
76
+
77
+
78
+ def extra_packages(extra: str) -> tuple[str, ...]:
79
+ """The extra's requirements for the running interpreter."""
80
+ if extra == "kasa":
81
+ # python-kasa >= 0.8 needs Python 3.11; 3.10 stays on 0.7.x.
82
+ if sys.version_info >= (3, 11):
83
+ return ("python-kasa>=0.12",)
84
+ return ("python-kasa>=0.7.7,<0.8",)
85
+ return _PACKAGES[extra]
86
+
87
+
88
+ def missing_extra_message(extra: str, what: str | None = None) -> str:
89
+ """One line: what is missing and how to install the extra.
90
+
91
+ ``what`` overrides the extra's description, for a command that
92
+ needs only part of it (e.g. password hashing from [web]).
93
+ """
94
+ what = what or EXTRAS[extra]
95
+ if extra == "sdwire" and sys.version_info < (3, 12):
96
+ # PyPI sdwire requires Python >= 3.12: on older interpreters the
97
+ # extra installs nothing, so no install command here would work.
98
+ running = f"{sys.version_info.major}.{sys.version_info.minor}"
99
+ return (
100
+ f"{what} needs Python 3.12+ (this is Python {running}); the "
101
+ f"[sdwire] extra installs nothing here. Install labctl under "
102
+ f"Python 3.12 or newer to use SDWire"
103
+ )
104
+ pipx = " ".join(f'"{p}"' for p in extra_packages(extra))
105
+ message = (
106
+ f"{what} is not installed. Install the [{extra}] extra: "
107
+ f'pip install "{DIST_NAME}[{extra}]" '
108
+ f"(pipx: pipx inject {DIST_NAME} {pipx})"
109
+ )
110
+ if extra == "sdwire":
111
+ message += "; sdwire needs Python 3.12+"
112
+ return message
113
+
114
+
115
+ def is_missing(error: ImportError, *modules: str) -> bool:
116
+ """True when ``error`` says one of ``modules`` is not installed.
117
+
118
+ Lets callers translate only the optional package's absence. Any
119
+ other ImportError, including ``cannot import name`` from an
120
+ installed but incompatible version (a plain ImportError, not
121
+ ModuleNotFoundError), keeps its real cause, as does a missing
122
+ submodule (``kasa.iot``): the package is there, but not a version
123
+ labctl can use.
124
+ """
125
+ if not isinstance(error, ModuleNotFoundError):
126
+ return False
127
+ name = error.name or ""
128
+ return name in modules
@@ -0,0 +1,43 @@
1
+ """
2
+ Health monitoring module for lab controller.
3
+
4
+ Provides health checks, status tracking, and alerting for SBCs.
5
+ """
6
+
7
+ from labctl.health.alerts import (
8
+ Alert,
9
+ AlertHandler,
10
+ AlertLevel,
11
+ AlertManager,
12
+ ConsoleAlertHandler,
13
+ EmailAlertHandler,
14
+ LogAlertHandler,
15
+ SlackAlertHandler,
16
+ )
17
+ from labctl.health.checks import (
18
+ CheckResult,
19
+ CheckType,
20
+ HealthChecker,
21
+ HealthCheckSummary,
22
+ )
23
+ from labctl.health.daemon import MonitorDaemon, format_check_table
24
+
25
+ __all__ = [
26
+ # Checks
27
+ "CheckType",
28
+ "CheckResult",
29
+ "HealthChecker",
30
+ "HealthCheckSummary",
31
+ # Alerts
32
+ "Alert",
33
+ "AlertLevel",
34
+ "AlertHandler",
35
+ "AlertManager",
36
+ "LogAlertHandler",
37
+ "ConsoleAlertHandler",
38
+ "EmailAlertHandler",
39
+ "SlackAlertHandler",
40
+ # Daemon
41
+ "MonitorDaemon",
42
+ "format_check_table",
43
+ ]
@@ -0,0 +1,275 @@
1
+ """
2
+ Alerting framework for lab controller.
3
+
4
+ Provides alert handling with pluggable handlers for different notification channels.
5
+ Currently implements log file output; email and Slack are stubs for future.
6
+ """
7
+
8
+ import logging
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class AlertLevel(Enum):
20
+ """Alert severity levels."""
21
+
22
+ INFO = "info"
23
+ WARNING = "warning"
24
+ CRITICAL = "critical"
25
+
26
+
27
+ @dataclass
28
+ class Alert:
29
+ """An alert to be sent to handlers."""
30
+
31
+ level: AlertLevel
32
+ sbc_name: str
33
+ message: str
34
+ timestamp: datetime = field(default_factory=datetime.now)
35
+ details: Optional[str] = None
36
+
37
+ def format(self) -> str:
38
+ """Format alert as a string."""
39
+ ts = self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
40
+ level = self.level.value.upper()
41
+ msg = f"[{ts}] [{level}] {self.sbc_name}: {self.message}"
42
+ if self.details:
43
+ msg += f" - {self.details}"
44
+ return msg
45
+
46
+
47
+ class AlertHandler(ABC):
48
+ """Abstract base class for alert handlers."""
49
+
50
+ @abstractmethod
51
+ def send(self, alert: Alert) -> bool:
52
+ """
53
+ Send an alert.
54
+
55
+ Args:
56
+ alert: Alert to send
57
+
58
+ Returns:
59
+ True if alert was sent successfully
60
+ """
61
+ pass
62
+
63
+ @abstractmethod
64
+ def close(self) -> None:
65
+ """Clean up handler resources."""
66
+ pass
67
+
68
+
69
+ class LogAlertHandler(AlertHandler):
70
+ """Alert handler that writes to a log file."""
71
+
72
+ def __init__(self, log_path: Path):
73
+ """
74
+ Initialize log alert handler.
75
+
76
+ Args:
77
+ log_path: Path to the alert log file
78
+ """
79
+ self.log_path = log_path
80
+ self._file_handle: Optional[object] = None
81
+ self._ensure_log_dir()
82
+
83
+ def _ensure_log_dir(self) -> None:
84
+ """Ensure the log directory exists."""
85
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
86
+
87
+ def send(self, alert: Alert) -> bool:
88
+ """Write alert to log file."""
89
+ try:
90
+ with open(self.log_path, "a") as f:
91
+ f.write(alert.format() + "\n")
92
+ return True
93
+ except Exception as e:
94
+ logger.error(f"Failed to write alert to log: {e}")
95
+ return False
96
+
97
+ def close(self) -> None:
98
+ """No cleanup needed for file-based logging."""
99
+ pass
100
+
101
+
102
+ class ConsoleAlertHandler(AlertHandler):
103
+ """Alert handler that prints to console."""
104
+
105
+ def __init__(self, min_level: AlertLevel = AlertLevel.INFO):
106
+ """
107
+ Initialize console alert handler.
108
+
109
+ Args:
110
+ min_level: Minimum alert level to display
111
+ """
112
+ self.min_level = min_level
113
+ self._level_order = {
114
+ AlertLevel.INFO: 0,
115
+ AlertLevel.WARNING: 1,
116
+ AlertLevel.CRITICAL: 2,
117
+ }
118
+
119
+ def send(self, alert: Alert) -> bool:
120
+ """Print alert to console if level meets threshold."""
121
+ if self._level_order[alert.level] >= self._level_order[self.min_level]:
122
+ # Color codes for different levels
123
+ colors = {
124
+ AlertLevel.INFO: "\033[32m", # Green
125
+ AlertLevel.WARNING: "\033[33m", # Yellow
126
+ AlertLevel.CRITICAL: "\033[31m", # Red
127
+ }
128
+ reset = "\033[0m"
129
+ color = colors.get(alert.level, "")
130
+ print(f"{color}{alert.format()}{reset}")
131
+ return True
132
+
133
+ def close(self) -> None:
134
+ """No cleanup needed for console output."""
135
+ pass
136
+
137
+
138
+ class EmailAlertHandler(AlertHandler):
139
+ """
140
+ Placeholder for email alert handler.
141
+
142
+ This is a stub for future implementation.
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ smtp_host: str = "",
148
+ smtp_port: int = 587,
149
+ sender: str = "",
150
+ recipients: Optional[list[str]] = None,
151
+ ):
152
+ """Initialize email handler (stub)."""
153
+ self.smtp_host = smtp_host
154
+ self.smtp_port = smtp_port
155
+ self.sender = sender
156
+ self.recipients = recipients or []
157
+ logger.warning("EmailAlertHandler is a stub - emails will not be sent")
158
+
159
+ def send(self, alert: Alert) -> bool:
160
+ """Stub: Log that email would be sent."""
161
+ logger.debug(
162
+ f"Email alert (stub): Would send to {self.recipients}: {alert.format()}"
163
+ )
164
+ return False # Return False since we didn't actually send
165
+
166
+ def close(self) -> None:
167
+ """No cleanup needed for stub."""
168
+ pass
169
+
170
+
171
+ class SlackAlertHandler(AlertHandler):
172
+ """
173
+ Placeholder for Slack webhook alert handler.
174
+
175
+ This is a stub for future implementation.
176
+ """
177
+
178
+ def __init__(self, webhook_url: str = ""):
179
+ """Initialize Slack handler (stub)."""
180
+ self.webhook_url = webhook_url
181
+ logger.warning("SlackAlertHandler is a stub - Slack messages will not be sent")
182
+
183
+ def send(self, alert: Alert) -> bool:
184
+ """Stub: Log that Slack message would be sent."""
185
+ logger.debug(f"Slack alert (stub): Would post to webhook: {alert.format()}")
186
+ return False # Return False since we didn't actually send
187
+
188
+ def close(self) -> None:
189
+ """No cleanup needed for stub."""
190
+ pass
191
+
192
+
193
+ class AlertManager:
194
+ """Manages multiple alert handlers and dispatches alerts."""
195
+
196
+ def __init__(self):
197
+ """Initialize alert manager."""
198
+ self._handlers: list[AlertHandler] = []
199
+
200
+ def add_handler(self, handler: AlertHandler) -> None:
201
+ """
202
+ Register an alert handler.
203
+
204
+ Args:
205
+ handler: Handler to add
206
+ """
207
+ self._handlers.append(handler)
208
+
209
+ def remove_handler(self, handler: AlertHandler) -> None:
210
+ """
211
+ Remove an alert handler.
212
+
213
+ Args:
214
+ handler: Handler to remove
215
+ """
216
+ if handler in self._handlers:
217
+ self._handlers.remove(handler)
218
+
219
+ def trigger(self, alert: Alert) -> int:
220
+ """
221
+ Send alert to all registered handlers.
222
+
223
+ Args:
224
+ alert: Alert to send
225
+
226
+ Returns:
227
+ Number of handlers that successfully sent the alert
228
+ """
229
+ success_count = 0
230
+ for handler in self._handlers:
231
+ try:
232
+ if handler.send(alert):
233
+ success_count += 1
234
+ except Exception as e:
235
+ logger.error(f"Handler {handler.__class__.__name__} failed: {e}")
236
+ return success_count
237
+
238
+ def trigger_info(self, sbc_name: str, message: str, details: str = None) -> int:
239
+ """Convenience method to trigger an INFO alert."""
240
+ alert = Alert(
241
+ level=AlertLevel.INFO,
242
+ sbc_name=sbc_name,
243
+ message=message,
244
+ details=details,
245
+ )
246
+ return self.trigger(alert)
247
+
248
+ def trigger_warning(self, sbc_name: str, message: str, details: str = None) -> int:
249
+ """Convenience method to trigger a WARNING alert."""
250
+ alert = Alert(
251
+ level=AlertLevel.WARNING,
252
+ sbc_name=sbc_name,
253
+ message=message,
254
+ details=details,
255
+ )
256
+ return self.trigger(alert)
257
+
258
+ def trigger_critical(self, sbc_name: str, message: str, details: str = None) -> int:
259
+ """Convenience method to trigger a CRITICAL alert."""
260
+ alert = Alert(
261
+ level=AlertLevel.CRITICAL,
262
+ sbc_name=sbc_name,
263
+ message=message,
264
+ details=details,
265
+ )
266
+ return self.trigger(alert)
267
+
268
+ def close(self) -> None:
269
+ """Close all handlers."""
270
+ for handler in self._handlers:
271
+ try:
272
+ handler.close()
273
+ except Exception as e:
274
+ logger.error(f"Error closing handler: {e}")
275
+ self._handlers.clear()