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
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
|