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
labctl/core/models.py ADDED
@@ -0,0 +1,815 @@
1
+ """
2
+ Data models for lab controller.
3
+
4
+ Defines dataclasses for SBCs, serial ports, network addresses, and power plugs.
5
+ """
6
+
7
+ import json
8
+ import sqlite3
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timedelta
11
+ from enum import Enum
12
+ from typing import Optional
13
+
14
+
15
+ class Status(Enum):
16
+ """SBC status values."""
17
+
18
+ UNKNOWN = "unknown"
19
+ ONLINE = "online"
20
+ OFFLINE = "offline"
21
+ BOOTING = "booting"
22
+ ERROR = "error"
23
+
24
+
25
+ class PortType(Enum):
26
+ """Serial port type values."""
27
+
28
+ CONSOLE = "console"
29
+ JTAG = "jtag"
30
+ DEBUG = "debug"
31
+
32
+
33
+ class AddressType(Enum):
34
+ """Network address type values."""
35
+
36
+ ETHERNET = "ethernet"
37
+ WIFI = "wifi"
38
+
39
+
40
+ class PlugType(Enum):
41
+ """Power plug type values."""
42
+
43
+ TASMOTA = "tasmota"
44
+ KASA = "kasa"
45
+ SHELLY = "shelly"
46
+
47
+
48
+ class ReleaseReason(str, Enum):
49
+ """Reasons a claim transitioned to released state."""
50
+
51
+ RELEASED = "released"
52
+ EXPIRED = "expired"
53
+ FORCE_RELEASED = "force-released"
54
+ SESSION_LOST = "session-lost"
55
+
56
+
57
+ class ClaimError(Exception):
58
+ """Base class for claim-related errors."""
59
+
60
+
61
+ class UnknownSBCError(ClaimError):
62
+ """Referenced SBC does not exist."""
63
+
64
+
65
+ class ClaimConflict(ClaimError):
66
+ """Another session holds an active claim on the target SBC."""
67
+
68
+ def __init__(self, claim: "Claim"):
69
+ self.claim = claim
70
+ holder = claim.agent_name or "unknown agent"
71
+ super().__init__(
72
+ f"SBC is claimed by '{holder}' until "
73
+ f"{claim.expires_at.isoformat() if claim.expires_at else 'unknown'}"
74
+ )
75
+
76
+
77
+ class ClaimNotFoundError(ClaimError):
78
+ """No active claim exists on the target SBC."""
79
+
80
+
81
+ class NotClaimantError(ClaimError):
82
+ """The calling session does not hold the active claim."""
83
+
84
+
85
+ class ClaimRequiredError(ClaimError):
86
+ """``claims.require_claim`` is on and the caller holds no claim."""
87
+
88
+ def __init__(self, sbc_name: str):
89
+ self.sbc_name = sbc_name
90
+ super().__init__(
91
+ f"SBC '{sbc_name}' must be claimed first (claims.require_claim is on)"
92
+ )
93
+
94
+
95
+ class NotOperatorError(ClaimError):
96
+ """Only operators may override or force-release another user's claim."""
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ClaimActor:
101
+ """Who is acting on a board, for claim checks (D013).
102
+
103
+ ``owner`` is the authenticated labctl username, if any: a claim made
104
+ by that user from any session (MCP, REST, an earlier server process)
105
+ counts as theirs. ``operator`` may override another user's claim; it
106
+ defaults to False so a caller that forgets to set it fails closed.
107
+ """
108
+
109
+ session_id: str
110
+ owner: Optional[str] = None
111
+ operator: bool = False
112
+
113
+ def holds(self, claim: "Claim") -> bool:
114
+ if claim.session_id == self.session_id:
115
+ return True
116
+ return bool(self.owner) and claim.owner == self.owner
117
+
118
+
119
+ class SessionKind(str, Enum):
120
+ """Transport/origin of a claim session."""
121
+
122
+ MCP_STDIO = "mcp-stdio"
123
+ MCP_HTTP = "mcp-http"
124
+ CLI = "cli"
125
+ WEB = "web"
126
+
127
+
128
+ @dataclass
129
+ class SerialDevice:
130
+ """Physical USB-serial adapter registered in the system."""
131
+
132
+ id: Optional[int] = None
133
+ name: str = ""
134
+ usb_path: str = ""
135
+ vendor: Optional[str] = None
136
+ model: Optional[str] = None
137
+ serial_number: Optional[str] = None
138
+ created_at: Optional[datetime] = None
139
+
140
+ @classmethod
141
+ def from_row(cls, row: sqlite3.Row) -> "SerialDevice":
142
+ """Create SerialDevice from database row."""
143
+ return cls(
144
+ id=row["id"],
145
+ name=row["name"],
146
+ usb_path=row["usb_path"],
147
+ vendor=row["vendor"],
148
+ model=row["model"],
149
+ serial_number=row["serial_number"],
150
+ created_at=row["created_at"],
151
+ )
152
+
153
+
154
+ @dataclass
155
+ class SerialPort:
156
+ """Serial port assignment linking a serial device to an SBC."""
157
+
158
+ id: Optional[int] = None
159
+ sbc_id: int = 0
160
+ port_type: PortType = PortType.CONSOLE
161
+ device_path: str = ""
162
+ tcp_port: Optional[int] = None
163
+ baud_rate: int = 115200
164
+ alias: Optional[str] = None
165
+ serial_device_id: Optional[int] = None
166
+ created_at: Optional[datetime] = None
167
+
168
+ # Populated by manager when loading relations
169
+ serial_device: Optional[SerialDevice] = None
170
+
171
+ @classmethod
172
+ def from_row(cls, row: sqlite3.Row) -> "SerialPort":
173
+ """Create SerialPort from database row."""
174
+ keys = row.keys()
175
+ return cls(
176
+ id=row["id"],
177
+ sbc_id=row["sbc_id"],
178
+ port_type=PortType(row["port_type"]),
179
+ device_path=row["device_path"],
180
+ tcp_port=row["tcp_port"],
181
+ baud_rate=row["baud_rate"],
182
+ alias=row["alias"] if "alias" in keys else None,
183
+ serial_device_id=(
184
+ row["serial_device_id"] if "serial_device_id" in keys else None
185
+ ),
186
+ created_at=row["created_at"],
187
+ )
188
+
189
+
190
+ @dataclass
191
+ class NetworkAddress:
192
+ """Network address for an SBC."""
193
+
194
+ id: Optional[int] = None
195
+ sbc_id: int = 0
196
+ address_type: AddressType = AddressType.ETHERNET
197
+ ip_address: str = ""
198
+ mac_address: Optional[str] = None
199
+ hostname: Optional[str] = None
200
+ created_at: Optional[datetime] = None
201
+
202
+ @classmethod
203
+ def from_row(cls, row: sqlite3.Row) -> "NetworkAddress":
204
+ """Create NetworkAddress from database row."""
205
+ return cls(
206
+ id=row["id"],
207
+ sbc_id=row["sbc_id"],
208
+ address_type=AddressType(row["address_type"]),
209
+ ip_address=row["ip_address"],
210
+ mac_address=row["mac_address"],
211
+ hostname=row["hostname"],
212
+ created_at=row["created_at"],
213
+ )
214
+
215
+
216
+ @dataclass
217
+ class PowerPlug:
218
+ """Power plug assignment for an SBC."""
219
+
220
+ id: Optional[int] = None
221
+ sbc_id: int = 0
222
+ plug_type: PlugType = PlugType.TASMOTA
223
+ address: str = ""
224
+ plug_index: int = 1
225
+ created_at: Optional[datetime] = None
226
+
227
+ @classmethod
228
+ def from_row(cls, row: sqlite3.Row) -> "PowerPlug":
229
+ """Create PowerPlug from database row."""
230
+ return cls(
231
+ id=row["id"],
232
+ sbc_id=row["sbc_id"],
233
+ plug_type=PlugType(row["plug_type"]),
234
+ address=row["address"],
235
+ plug_index=row["plug_index"],
236
+ created_at=row["created_at"],
237
+ )
238
+
239
+
240
+ @dataclass
241
+ class SDWireDevice:
242
+ """SD card multiplexer (SDWire/SDWireC/SDWire3) device."""
243
+
244
+ id: Optional[int] = None
245
+ name: str = ""
246
+ serial_number: str = ""
247
+ device_type: str = "sdwirec" # sdwire, sdwirec, sdwire3
248
+ created_at: Optional[datetime] = None
249
+
250
+ @classmethod
251
+ def from_row(cls, row: sqlite3.Row) -> "SDWireDevice":
252
+ """Create SDWireDevice from database row."""
253
+ return cls(
254
+ id=row["id"],
255
+ name=row["name"],
256
+ serial_number=row["serial_number"],
257
+ device_type=row["device_type"],
258
+ created_at=row["created_at"],
259
+ )
260
+
261
+
262
+ @dataclass
263
+ class SBC:
264
+ """Single Board Computer record."""
265
+
266
+ id: Optional[int] = None
267
+ name: str = ""
268
+ project: Optional[str] = None
269
+ description: Optional[str] = None
270
+ ssh_user: str = "root"
271
+ status: Status = Status.UNKNOWN
272
+ created_at: Optional[datetime] = None
273
+ updated_at: Optional[datetime] = None
274
+
275
+ # Cached power observation written by the monitor daemon every cycle.
276
+ # Read by `labctl status --fast` to avoid live network probes.
277
+ last_power_state: Optional[str] = None # "on" | "off" | "unknown"
278
+ last_power_at: Optional[str] = None
279
+
280
+ # Per-SBC power-cycle off→on delay. None means "use the global default".
281
+ power_cycle_delay_seconds: Optional[float] = None
282
+
283
+ # Related objects (populated by manager)
284
+ serial_ports: list[SerialPort] = field(default_factory=list)
285
+ network_addresses: list[NetworkAddress] = field(default_factory=list)
286
+ power_plug: Optional[PowerPlug] = None
287
+ sdwire: Optional[SDWireDevice] = None
288
+
289
+ @classmethod
290
+ def from_row(cls, row: sqlite3.Row) -> "SBC":
291
+ """Create SBC from database row."""
292
+ # Pre-v6 rows may not have the cached-power columns; tolerate that.
293
+ try:
294
+ last_power_state = row["last_power_state"]
295
+ except (IndexError, KeyError):
296
+ last_power_state = None
297
+ try:
298
+ last_power_at = row["last_power_at"]
299
+ except (IndexError, KeyError):
300
+ last_power_at = None
301
+ # Pre-v7 rows lack the cycle-delay column.
302
+ try:
303
+ power_cycle_delay_seconds = row["power_cycle_delay_seconds"]
304
+ except (IndexError, KeyError):
305
+ power_cycle_delay_seconds = None
306
+ return cls(
307
+ id=row["id"],
308
+ name=row["name"],
309
+ project=row["project"],
310
+ description=row["description"],
311
+ ssh_user=row["ssh_user"],
312
+ status=Status(row["status"]),
313
+ created_at=row["created_at"],
314
+ updated_at=row["updated_at"],
315
+ last_power_state=last_power_state,
316
+ last_power_at=last_power_at,
317
+ power_cycle_delay_seconds=power_cycle_delay_seconds,
318
+ )
319
+
320
+ @property
321
+ def console_port(self) -> Optional[SerialPort]:
322
+ """Get console serial port if assigned."""
323
+ for port in self.serial_ports:
324
+ if port.port_type == PortType.CONSOLE:
325
+ return port
326
+ return None
327
+
328
+ @property
329
+ def primary_ip(self) -> Optional[str]:
330
+ """Get primary IP address (ethernet preferred)."""
331
+ for addr in self.network_addresses:
332
+ if addr.address_type == AddressType.ETHERNET:
333
+ return addr.ip_address
334
+ if self.network_addresses:
335
+ return self.network_addresses[0].ip_address
336
+ return None
337
+
338
+ def to_dict(self, include_ids: bool = False) -> dict:
339
+ """Convert SBC to JSON-serializable dict.
340
+
341
+ Args:
342
+ include_ids: Include database IDs in output (for REST API).
343
+ """
344
+ data = {
345
+ "name": self.name,
346
+ "project": self.project,
347
+ "description": self.description,
348
+ "ssh_user": self.ssh_user,
349
+ "status": self.status.value,
350
+ "primary_ip": self.primary_ip,
351
+ }
352
+ if include_ids:
353
+ data["id"] = self.id
354
+
355
+ if self.serial_ports:
356
+ data["serial_ports"] = [
357
+ {
358
+ **({"id": p.id} if include_ids else {}),
359
+ "type": p.port_type.value,
360
+ "device": p.device_path,
361
+ "alias": p.alias,
362
+ "tcp_port": p.tcp_port,
363
+ "baud_rate": p.baud_rate,
364
+ "serial_device": p.serial_device.name if p.serial_device else None,
365
+ }
366
+ for p in self.serial_ports
367
+ ]
368
+
369
+ if self.network_addresses:
370
+ data["network_addresses"] = [
371
+ {
372
+ **({"id": a.id} if include_ids else {}),
373
+ "type": a.address_type.value,
374
+ "ip": a.ip_address,
375
+ "mac": a.mac_address,
376
+ "hostname": a.hostname,
377
+ }
378
+ for a in self.network_addresses
379
+ ]
380
+
381
+ if self.power_plug:
382
+ data["power_plug"] = {
383
+ **({"id": self.power_plug.id} if include_ids else {}),
384
+ "type": self.power_plug.plug_type.value,
385
+ "address": self.power_plug.address,
386
+ "index": self.power_plug.plug_index,
387
+ }
388
+
389
+ if self.sdwire:
390
+ data["sdwire"] = {
391
+ "name": self.sdwire.name,
392
+ "serial_number": self.sdwire.serial_number,
393
+ "device_type": self.sdwire.device_type,
394
+ }
395
+
396
+ return data
397
+
398
+
399
+ def _parse_timestamp(value) -> Optional[datetime]:
400
+ """Parse a SQLite TIMESTAMP value into a datetime."""
401
+ if value is None:
402
+ return None
403
+ if isinstance(value, datetime):
404
+ return value
405
+ # SQLite returns timestamps as strings in ISO-like format
406
+ # ("YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS[.ffffff]").
407
+ s = str(value).replace("T", " ")
408
+ for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
409
+ try:
410
+ return datetime.strptime(s, fmt)
411
+ except ValueError:
412
+ continue
413
+ return None
414
+
415
+
416
+ @dataclass
417
+ class ClaimRequest:
418
+ """A polite release request recorded against an active claim."""
419
+
420
+ id: Optional[int] = None
421
+ claim_id: int = 0
422
+ requested_by: str = ""
423
+ reason: str = ""
424
+ requested_at: Optional[datetime] = None
425
+ acknowledged: bool = False
426
+
427
+ @classmethod
428
+ def from_row(cls, row: sqlite3.Row) -> "ClaimRequest":
429
+ return cls(
430
+ id=row["id"],
431
+ claim_id=row["claim_id"],
432
+ requested_by=row["requested_by"],
433
+ reason=row["reason"],
434
+ requested_at=_parse_timestamp(row["requested_at"]),
435
+ acknowledged=bool(row["acknowledged"]),
436
+ )
437
+
438
+ def to_dict(self) -> dict:
439
+ return {
440
+ "id": self.id,
441
+ "requested_by": self.requested_by,
442
+ "reason": self.reason,
443
+ "requested_at": (
444
+ self.requested_at.isoformat() if self.requested_at else None
445
+ ),
446
+ "acknowledged": self.acknowledged,
447
+ }
448
+
449
+
450
+ @dataclass
451
+ class Claim:
452
+ """Exclusive-access claim on an SBC.
453
+
454
+ Expiry is computed as ``last_activity + duration_seconds``; the
455
+ ``expires_at`` column is a materialized view of that rule, rewritten
456
+ alongside ``last_activity`` on every heartbeat.
457
+ """
458
+
459
+ id: Optional[int] = None
460
+ sbc_id: int = 0
461
+ agent_name: str = ""
462
+ session_id: str = ""
463
+ session_kind: str = ""
464
+ # Authenticated labctl username (MCP HTTP, REST, web login), else None.
465
+ owner: Optional[str] = None
466
+ reason: str = ""
467
+ context: Optional[dict] = None
468
+ acquired_at: Optional[datetime] = None
469
+ duration_seconds: int = 0
470
+ last_activity: Optional[datetime] = None
471
+ expires_at: Optional[datetime] = None
472
+ renewal_count: int = 0
473
+ released_at: Optional[datetime] = None
474
+ release_reason: Optional[ReleaseReason] = None
475
+ released_by: Optional[str] = None
476
+ pending_requests: list[ClaimRequest] = field(default_factory=list)
477
+
478
+ # Populated by manager when loading relations
479
+ sbc_name: Optional[str] = None
480
+
481
+ @classmethod
482
+ def from_row(cls, row: sqlite3.Row) -> "Claim":
483
+ keys = row.keys()
484
+ context = None
485
+ if row["context_json"]:
486
+ try:
487
+ context = json.loads(row["context_json"])
488
+ except (json.JSONDecodeError, TypeError):
489
+ context = None
490
+ release_reason = None
491
+ if row["release_reason"]:
492
+ try:
493
+ release_reason = ReleaseReason(row["release_reason"])
494
+ except ValueError:
495
+ release_reason = None
496
+ return cls(
497
+ id=row["id"],
498
+ sbc_id=row["sbc_id"],
499
+ agent_name=row["agent_name"],
500
+ session_id=row["session_id"],
501
+ session_kind=row["session_kind"],
502
+ owner=row["owner"] if "owner" in keys else None,
503
+ reason=row["reason"],
504
+ context=context,
505
+ acquired_at=_parse_timestamp(row["acquired_at"]),
506
+ duration_seconds=row["duration_seconds"],
507
+ last_activity=_parse_timestamp(row["last_activity"]),
508
+ expires_at=_parse_timestamp(row["expires_at"]),
509
+ renewal_count=row["renewal_count"],
510
+ released_at=_parse_timestamp(row["released_at"]),
511
+ release_reason=release_reason,
512
+ released_by=row["released_by"],
513
+ sbc_name=row["sbc_name"] if "sbc_name" in keys else None,
514
+ )
515
+
516
+ @property
517
+ def is_active(self) -> bool:
518
+ """True if the claim is unreleased and within its deadline."""
519
+ if self.released_at is not None:
520
+ return False
521
+ if self.expires_at is not None and datetime.now() > self.expires_at:
522
+ return False
523
+ return True
524
+
525
+ @property
526
+ def time_remaining(self) -> Optional[timedelta]:
527
+ if not self.is_active or self.expires_at is None:
528
+ return None
529
+ return self.expires_at - datetime.now()
530
+
531
+ def to_dict(self, include_ids: bool = False) -> dict:
532
+ data = {
533
+ "agent_name": self.agent_name,
534
+ "session_kind": self.session_kind,
535
+ "reason": self.reason,
536
+ "acquired_at": (self.acquired_at.isoformat() if self.acquired_at else None),
537
+ "expires_at": (self.expires_at.isoformat() if self.expires_at else None),
538
+ "last_activity": (
539
+ self.last_activity.isoformat() if self.last_activity else None
540
+ ),
541
+ "duration_seconds": self.duration_seconds,
542
+ "renewal_count": self.renewal_count,
543
+ "is_active": self.is_active,
544
+ }
545
+ if self.owner:
546
+ data["owner"] = self.owner
547
+ remaining = self.time_remaining
548
+ if remaining is not None:
549
+ data["time_remaining_seconds"] = max(0, int(remaining.total_seconds()))
550
+ if self.context:
551
+ data["context"] = self.context
552
+ if self.sbc_name:
553
+ data["sbc_name"] = self.sbc_name
554
+ if self.released_at is not None:
555
+ data["released_at"] = self.released_at.isoformat()
556
+ data["release_reason"] = (
557
+ self.release_reason.value if self.release_reason else None
558
+ )
559
+ data["released_by"] = self.released_by
560
+ if self.pending_requests:
561
+ data["pending_requests"] = [r.to_dict() for r in self.pending_requests]
562
+ if include_ids:
563
+ data["id"] = self.id
564
+ data["sbc_id"] = self.sbc_id
565
+ return data
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Actuators / bindings (schema v8)
570
+ # ---------------------------------------------------------------------------
571
+
572
+
573
+ class ActuatorKind(str, Enum):
574
+ """Top-level category of an actuator."""
575
+
576
+ RELAY = "relay"
577
+ GPIO = "gpio"
578
+
579
+
580
+ class DriverName(str, Enum):
581
+ """Concrete driver implementations.
582
+
583
+ v1 ships ``LCUS1_SERIAL``; the others are placeholders for future
584
+ driver work referenced by the spec but not yet implemented.
585
+ """
586
+
587
+ LCUS1_SERIAL = "lcus1_serial"
588
+ NUMATO_ACM = "numato_acm"
589
+ HID_GENERIC = "hid_generic"
590
+ SYSFS_GPIO = "sysfs_gpio"
591
+
592
+
593
+ class ChannelState(str, Enum):
594
+ """Physical state of a relay contact."""
595
+
596
+ OPEN = "open"
597
+ CLOSED = "closed"
598
+
599
+
600
+ class ShapeMode(str, Enum):
601
+ """Whether a binding represents sustained or pulsed actuation."""
602
+
603
+ LATCH = "latch"
604
+ MOMENTARY = "momentary"
605
+
606
+
607
+ class SamplePhase(str, Enum):
608
+ """When the binding's value is consequential relative to power."""
609
+
610
+ PRE_POWER = "pre_power"
611
+ POST_POWER = "post_power" # stored by older versions; never acted on
612
+ NONE = "none"
613
+
614
+
615
+ # post_power was accepted before anything acted on it. New bindings can't
616
+ # use it; ones already stored are shown as inactive.
617
+ POST_POWER_REFUSED = (
618
+ "post-power bindings aren't implemented yet: nothing would set the "
619
+ "relay after power-on. Use phase none (or pre-power for a strap that "
620
+ "must be set before power-on)"
621
+ )
622
+ POST_POWER_INACTIVE = "inactive: post_power isn't acted on yet"
623
+
624
+
625
+ class DesiredState(str, Enum):
626
+ """Operator intent for a binding (persistent across daemon restarts)."""
627
+
628
+ ASSERTED = "asserted"
629
+ RELEASED = "released"
630
+ FOLLOWING_POWER = "following_power"
631
+
632
+
633
+ # Recommended purposes — the binding `purpose` column stores any string,
634
+ # but provisioning UIs should default to / suggest these.
635
+ KNOWN_PURPOSES = (
636
+ "recovery_mode",
637
+ "boot_select",
638
+ "power_button",
639
+ "reset_button",
640
+ "usb_data_break",
641
+ "usb_power_break",
642
+ "dut_input",
643
+ )
644
+
645
+
646
+ @dataclass
647
+ class Actuator:
648
+ """A USB-controlled relay (or similar single-bit actuator)."""
649
+
650
+ id: Optional[int] = None
651
+ name: str = ""
652
+ kind: ActuatorKind = ActuatorKind.RELAY
653
+ driver: DriverName = DriverName.LCUS1_SERIAL
654
+ device_path: Optional[str] = None
655
+ vid: Optional[str] = None
656
+ pid: Optional[str] = None
657
+ serial_no: Optional[str] = None
658
+ last_probe_at: Optional[datetime] = None
659
+ last_probe_result: Optional[str] = None
660
+ created_at: Optional[datetime] = None
661
+
662
+ # Populated by the manager when relations are loaded.
663
+ channels: list["ActuatorChannel"] = field(default_factory=list)
664
+
665
+ @classmethod
666
+ def from_row(cls, row: sqlite3.Row) -> "Actuator":
667
+ return cls(
668
+ id=row["id"],
669
+ name=row["name"],
670
+ kind=ActuatorKind(row["kind"]),
671
+ driver=DriverName(row["driver"]),
672
+ device_path=row["device_path"],
673
+ vid=row["vid"],
674
+ pid=row["pid"],
675
+ serial_no=row["serial_no"],
676
+ last_probe_at=_parse_timestamp(row["last_probe_at"]),
677
+ last_probe_result=row["last_probe_result"],
678
+ created_at=row["created_at"],
679
+ )
680
+
681
+ def to_dict(self, include_ids: bool = False) -> dict:
682
+ data: dict = {
683
+ "name": self.name,
684
+ "kind": self.kind.value,
685
+ "driver": self.driver.value,
686
+ "device_path": self.device_path,
687
+ "vid": self.vid,
688
+ "pid": self.pid,
689
+ "serial_no": self.serial_no,
690
+ "last_probe_at": (
691
+ self.last_probe_at.isoformat() if self.last_probe_at else None
692
+ ),
693
+ "last_probe_result": self.last_probe_result,
694
+ }
695
+ if include_ids:
696
+ data["id"] = self.id
697
+ if self.channels:
698
+ data["channels"] = [
699
+ c.to_dict(include_ids=include_ids) for c in self.channels
700
+ ]
701
+ return data
702
+
703
+
704
+ @dataclass
705
+ class ActuatorChannel:
706
+ """One outlet / channel on an actuator."""
707
+
708
+ id: Optional[int] = None
709
+ actuator_id: int = 0
710
+ channel_index: int = 1 # 1-based
711
+ label: Optional[str] = None
712
+ default_state: ChannelState = ChannelState.OPEN
713
+ last_state: Optional[ChannelState] = None
714
+ last_changed_at: Optional[datetime] = None
715
+ cycle_count: int = 0
716
+ created_at: Optional[datetime] = None
717
+
718
+ @classmethod
719
+ def from_row(cls, row: sqlite3.Row) -> "ActuatorChannel":
720
+ last_raw = row["last_state"]
721
+ return cls(
722
+ id=row["id"],
723
+ actuator_id=row["actuator_id"],
724
+ channel_index=row["channel_index"],
725
+ label=row["label"],
726
+ default_state=ChannelState(row["default_state"]),
727
+ last_state=ChannelState(last_raw) if last_raw else None,
728
+ last_changed_at=_parse_timestamp(row["last_changed_at"]),
729
+ cycle_count=row["cycle_count"],
730
+ created_at=row["created_at"],
731
+ )
732
+
733
+ def to_dict(self, include_ids: bool = False) -> dict:
734
+ data: dict = {
735
+ "channel_index": self.channel_index,
736
+ "label": self.label,
737
+ "default_state": self.default_state.value,
738
+ "last_state": self.last_state.value if self.last_state else None,
739
+ "last_changed_at": (
740
+ self.last_changed_at.isoformat() if self.last_changed_at else None
741
+ ),
742
+ "cycle_count": self.cycle_count,
743
+ }
744
+ if include_ids:
745
+ data["id"] = self.id
746
+ data["actuator_id"] = self.actuator_id
747
+ return data
748
+
749
+
750
+ @dataclass
751
+ class Binding:
752
+ """A (target SBC, purpose) → actuator-channel association."""
753
+
754
+ id: Optional[int] = None
755
+ sbc_id: int = 0
756
+ purpose: str = ""
757
+ actuator_channel_id: int = 0
758
+ shape_mode: ShapeMode = ShapeMode.LATCH
759
+ shape_active: ChannelState = ChannelState.CLOSED
760
+ momentary_pulse_ms: Optional[int] = None
761
+ sample_phase: SamplePhase = SamplePhase.NONE
762
+ desired_state: DesiredState = DesiredState.RELEASED
763
+ notes: Optional[str] = None
764
+ created_at: Optional[datetime] = None
765
+
766
+ # Populated by manager when joining for display.
767
+ sbc_name: Optional[str] = None
768
+ actuator_name: Optional[str] = None
769
+ channel_index: Optional[int] = None
770
+
771
+ @classmethod
772
+ def from_row(cls, row: sqlite3.Row) -> "Binding":
773
+ keys = row.keys()
774
+ return cls(
775
+ id=row["id"],
776
+ sbc_id=row["sbc_id"],
777
+ purpose=row["purpose"],
778
+ actuator_channel_id=row["actuator_channel_id"],
779
+ shape_mode=ShapeMode(row["shape_mode"]),
780
+ shape_active=ChannelState(row["shape_active"]),
781
+ momentary_pulse_ms=row["momentary_pulse_ms"],
782
+ sample_phase=SamplePhase(row["sample_phase"]),
783
+ desired_state=DesiredState(row["desired_state"]),
784
+ notes=row["notes"],
785
+ created_at=row["created_at"],
786
+ sbc_name=row["sbc_name"] if "sbc_name" in keys else None,
787
+ actuator_name=(row["actuator_name"] if "actuator_name" in keys else None),
788
+ channel_index=(row["channel_index"] if "channel_index" in keys else None),
789
+ )
790
+
791
+ def to_dict(self, include_ids: bool = False) -> dict:
792
+ data: dict = {
793
+ "purpose": self.purpose,
794
+ "shape": {
795
+ "mode": self.shape_mode.value,
796
+ "active": self.shape_active.value,
797
+ "momentary_pulse_ms": self.momentary_pulse_ms,
798
+ "sample_phase": self.sample_phase.value,
799
+ },
800
+ "desired_state": self.desired_state.value,
801
+ "notes": self.notes,
802
+ }
803
+ if self.sample_phase is SamplePhase.POST_POWER:
804
+ data["shape"]["sample_phase_note"] = POST_POWER_INACTIVE
805
+ if self.sbc_name:
806
+ data["sbc_name"] = self.sbc_name
807
+ if self.actuator_name:
808
+ data["actuator_name"] = self.actuator_name
809
+ if self.channel_index is not None:
810
+ data["channel_index"] = self.channel_index
811
+ if include_ids:
812
+ data["id"] = self.id
813
+ data["sbc_id"] = self.sbc_id
814
+ data["actuator_channel_id"] = self.actuator_channel_id
815
+ return data