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/manager.py ADDED
@@ -0,0 +1,2815 @@
1
+ """
2
+ Resource manager for lab controller.
3
+
4
+ Provides CRUD operations for SBCs, serial ports, network addresses, and power plugs.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import math
10
+ import os
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Iterable, Optional
14
+
15
+ from labctl.core import audit
16
+ from labctl.core.database import DEFAULT_TIMEOUT_SECONDS as DEFAULT_DB_TIMEOUT
17
+ from labctl.core.database import Database, get_database
18
+ from labctl.core.models import (
19
+ POST_POWER_REFUSED,
20
+ SBC,
21
+ Actuator,
22
+ ActuatorChannel,
23
+ ActuatorKind,
24
+ AddressType,
25
+ Binding,
26
+ ChannelState,
27
+ Claim,
28
+ ClaimActor,
29
+ ClaimConflict,
30
+ ClaimNotFoundError,
31
+ ClaimRequest,
32
+ ClaimRequiredError,
33
+ DesiredState,
34
+ DriverName,
35
+ NetworkAddress,
36
+ NotClaimantError,
37
+ NotOperatorError,
38
+ PlugType,
39
+ PortType,
40
+ PowerPlug,
41
+ ReleaseReason,
42
+ SamplePhase,
43
+ SDWireDevice,
44
+ SerialDevice,
45
+ SerialPort,
46
+ ShapeMode,
47
+ Status,
48
+ UnknownSBCError,
49
+ )
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ def _norm_usb_id(value: str) -> str:
55
+ """``1A86``, ``0x1a86`` and ``0x01a86`` name the same USB VID/PID."""
56
+ value = value.strip()
57
+ try:
58
+ return format(int(value, 16), "04x")
59
+ except ValueError:
60
+ return value.lower()
61
+
62
+
63
+ def outlet_key(plug_type, address: str, plug_index: int) -> tuple:
64
+ """A plug outlet as the sharing rule compares it: type, address text
65
+ (trimmed, case-folded) and outlet index. Two different addresses for
66
+ one plug (a hostname and its IP) are not recognised as the same."""
67
+ plug_type = getattr(plug_type, "value", plug_type)
68
+ index = plug_index if plug_index is not None else 1
69
+ return (plug_type, (address or "").strip().lower(), index)
70
+
71
+
72
+ def outlets_overlap(a: tuple, b: tuple) -> bool:
73
+ """Whether switching outlet ``a`` switches outlet ``b`` (keys from
74
+ :func:`outlet_key`). An index below 1 (rows saved before assignment
75
+ refused it) addresses the whole device on Tasmota (Power0), so it
76
+ overlaps every outlet on that address."""
77
+ return a[:2] == b[:2] and (a[2] == b[2] or a[2] < 1 or b[2] < 1)
78
+
79
+
80
+ def plug_outlet_key(plug) -> tuple:
81
+ """:func:`outlet_key` of a PowerPlug."""
82
+ return outlet_key(plug.plug_type, plug.address, plug.plug_index)
83
+
84
+
85
+ class ResourceManager:
86
+ """Manager for lab resources (SBCs, ports, etc.)."""
87
+
88
+ def __init__(self, db: Database, *, base_tcp_port: int = 4000):
89
+ """
90
+ Initialize resource manager.
91
+
92
+ Args:
93
+ db: Initialized database instance
94
+ base_tcp_port: Lowest TCP port auto-assignment gives a serial
95
+ port (``serial.base_tcp_port``)
96
+ """
97
+ self.db = db
98
+ self.base_tcp_port = base_tcp_port
99
+ # cleanup_old_status_logs: what status_log looked like after the
100
+ # last prune, so an hourly call with nothing new to prune is cheap.
101
+ self._prune_state: Optional[tuple] = None
102
+
103
+ # --- SBC Operations ---
104
+
105
+ def create_sbc(
106
+ self,
107
+ name: str,
108
+ project: Optional[str] = None,
109
+ description: Optional[str] = None,
110
+ ssh_user: str = "root",
111
+ ) -> SBC:
112
+ """
113
+ Create a new SBC record.
114
+
115
+ Args:
116
+ name: Unique name for the SBC
117
+ project: Optional project name
118
+ description: Optional description
119
+ ssh_user: SSH username (default: root)
120
+
121
+ Returns:
122
+ Created SBC instance
123
+
124
+ Raises:
125
+ ValueError: If name already exists
126
+ """
127
+ # Check for duplicate name
128
+ existing = self.get_sbc_by_name(name)
129
+ if existing:
130
+ raise ValueError(f"SBC with name '{name}' already exists")
131
+
132
+ sbc_id = self.db.execute_insert(
133
+ """
134
+ INSERT INTO sbcs (name, project, description, ssh_user)
135
+ VALUES (?, ?, ?, ?)
136
+ """,
137
+ (name, project, description, ssh_user),
138
+ )
139
+
140
+ self._audit_log("create", "sbc", sbc_id, name, f"Created SBC: {name}")
141
+
142
+ return self.get_sbc(sbc_id)
143
+
144
+ def get_sbc(self, sbc_id: int) -> Optional[SBC]:
145
+ """Get SBC by ID with all related objects."""
146
+ row = self.db.execute_one("SELECT * FROM sbcs WHERE id = ?", (sbc_id,))
147
+ if not row:
148
+ return None
149
+
150
+ sbc = SBC.from_row(row)
151
+ self._load_sbc_relations(sbc)
152
+ return sbc
153
+
154
+ def get_sbc_by_name(self, name: str) -> Optional[SBC]:
155
+ """Get SBC by name with all related objects."""
156
+ row = self.db.execute_one("SELECT * FROM sbcs WHERE name = ?", (name,))
157
+ if not row:
158
+ return None
159
+
160
+ sbc = SBC.from_row(row)
161
+ self._load_sbc_relations(sbc)
162
+ return sbc
163
+
164
+ def _load_serial_device(self, port: SerialPort) -> None:
165
+ """Load the serial device for a port if it has one."""
166
+ if port.serial_device_id:
167
+ dev_row = self.db.execute_one(
168
+ "SELECT * FROM serial_devices WHERE id = ?",
169
+ (port.serial_device_id,),
170
+ )
171
+ if dev_row:
172
+ port.serial_device = SerialDevice.from_row(dev_row)
173
+
174
+ def _load_sbc_relations(self, sbc: SBC) -> None:
175
+ """Load related objects for an SBC."""
176
+ # Load serial ports
177
+ rows = self.db.execute("SELECT * FROM serial_ports WHERE sbc_id = ?", (sbc.id,))
178
+ sbc.serial_ports = [SerialPort.from_row(r) for r in rows]
179
+ for port in sbc.serial_ports:
180
+ self._load_serial_device(port)
181
+
182
+ # Load network addresses
183
+ rows = self.db.execute(
184
+ "SELECT * FROM network_addresses WHERE sbc_id = ?", (sbc.id,)
185
+ )
186
+ sbc.network_addresses = [NetworkAddress.from_row(r) for r in rows]
187
+
188
+ # Load power plug
189
+ row = self.db.execute_one(
190
+ "SELECT * FROM power_plugs WHERE sbc_id = ?", (sbc.id,)
191
+ )
192
+ if row:
193
+ sbc.power_plug = PowerPlug.from_row(row)
194
+
195
+ # Load SDWire assignment
196
+ row = self.db.execute_one(
197
+ """SELECT sd.* FROM sdwire_devices sd
198
+ JOIN sdwire_assignments sa ON sa.sdwire_device_id = sd.id
199
+ WHERE sa.sbc_id = ?""",
200
+ (sbc.id,),
201
+ )
202
+ if row:
203
+ sbc.sdwire = SDWireDevice.from_row(row)
204
+
205
+ def _load_sbc_relations_batch(self, sbcs: list[SBC]) -> None:
206
+ """Load related objects for many SBCs using batched queries."""
207
+ if not sbcs:
208
+ return
209
+
210
+ sbc_by_id = {sbc.id: sbc for sbc in sbcs if sbc.id is not None}
211
+ if not sbc_by_id:
212
+ return
213
+
214
+ for sbc in sbcs:
215
+ sbc.serial_ports = []
216
+ sbc.network_addresses = []
217
+ sbc.power_plug = None
218
+ sbc.sdwire = None
219
+
220
+ placeholders = ",".join("?" for _ in sbc_by_id)
221
+ sbc_ids = tuple(sbc_by_id.keys())
222
+
223
+ serial_ports_by_device_id: dict[int, list[SerialPort]] = {}
224
+ port_rows = self.db.execute(
225
+ f"""
226
+ SELECT * FROM serial_ports
227
+ WHERE sbc_id IN ({placeholders})
228
+ ORDER BY sbc_id, id
229
+ """,
230
+ sbc_ids,
231
+ )
232
+ for row in port_rows:
233
+ port = SerialPort.from_row(row)
234
+ sbc_by_id[port.sbc_id].serial_ports.append(port)
235
+ if port.serial_device_id is not None:
236
+ serial_ports_by_device_id.setdefault(port.serial_device_id, []).append(
237
+ port
238
+ )
239
+
240
+ if serial_ports_by_device_id:
241
+ device_placeholders = ",".join("?" for _ in serial_ports_by_device_id)
242
+ device_rows = self.db.execute(
243
+ f"""
244
+ SELECT * FROM serial_devices
245
+ WHERE id IN ({device_placeholders})
246
+ """,
247
+ tuple(serial_ports_by_device_id.keys()),
248
+ )
249
+ for row in device_rows:
250
+ device = SerialDevice.from_row(row)
251
+ for port in serial_ports_by_device_id.get(device.id, []):
252
+ port.serial_device = device
253
+
254
+ address_rows = self.db.execute(
255
+ f"""
256
+ SELECT * FROM network_addresses
257
+ WHERE sbc_id IN ({placeholders})
258
+ ORDER BY sbc_id, id
259
+ """,
260
+ sbc_ids,
261
+ )
262
+ for row in address_rows:
263
+ address = NetworkAddress.from_row(row)
264
+ sbc_by_id[address.sbc_id].network_addresses.append(address)
265
+
266
+ power_rows = self.db.execute(
267
+ f"""
268
+ SELECT * FROM power_plugs
269
+ WHERE sbc_id IN ({placeholders})
270
+ """,
271
+ sbc_ids,
272
+ )
273
+ for row in power_rows:
274
+ plug = PowerPlug.from_row(row)
275
+ sbc_by_id[plug.sbc_id].power_plug = plug
276
+
277
+ sdwire_rows = self.db.execute(
278
+ f"""
279
+ SELECT sd.*, sa.sbc_id AS assigned_sbc_id
280
+ FROM sdwire_devices sd
281
+ JOIN sdwire_assignments sa ON sa.sdwire_device_id = sd.id
282
+ WHERE sa.sbc_id IN ({placeholders})
283
+ """,
284
+ sbc_ids,
285
+ )
286
+ for row in sdwire_rows:
287
+ sbc_by_id[row["assigned_sbc_id"]].sdwire = SDWireDevice.from_row(row)
288
+
289
+ def list_sbcs(
290
+ self,
291
+ project: Optional[str] = None,
292
+ status: Optional[Status] = None,
293
+ ) -> list[SBC]:
294
+ """
295
+ List all SBCs with optional filters.
296
+
297
+ Args:
298
+ project: Filter by project name
299
+ status: Filter by status
300
+
301
+ Returns:
302
+ List of SBC instances
303
+ """
304
+ sql = "SELECT * FROM sbcs WHERE 1=1"
305
+ params = []
306
+
307
+ if project:
308
+ sql += " AND project = ?"
309
+ params.append(project)
310
+
311
+ if status:
312
+ sql += " AND status = ?"
313
+ params.append(status.value)
314
+
315
+ sql += " ORDER BY name"
316
+
317
+ rows = self.db.execute(sql, tuple(params))
318
+ sbcs = [SBC.from_row(row) for row in rows]
319
+ self._load_sbc_relations_batch(sbcs)
320
+ return sbcs
321
+
322
+ def update_sbc(
323
+ self,
324
+ sbc_id: int,
325
+ name: Optional[str] = None,
326
+ project: Optional[str] = None,
327
+ description: Optional[str] = None,
328
+ ssh_user: Optional[str] = None,
329
+ status: Optional[Status] = None,
330
+ power_cycle_delay_seconds: Optional[float] = None,
331
+ ) -> Optional[SBC]:
332
+ """Update SBC fields.
333
+
334
+ ``power_cycle_delay_seconds`` accepts 0 to ``MAX_CYCLE_DELAY_SECONDS``
335
+ to set (ValueError otherwise, before anything is written), or a
336
+ negative value as the sentinel for "clear back to default".
337
+ """
338
+ sbc = self.get_sbc(sbc_id)
339
+ if not sbc:
340
+ return None
341
+
342
+ updates = []
343
+ params = []
344
+ # Human-readable per-field summaries for the audit log.
345
+ changes: list[str] = []
346
+
347
+ if name is not None:
348
+ updates.append("name = ?")
349
+ params.append(name)
350
+ changes.append(f"name: {sbc.name!r} -> {name!r}")
351
+
352
+ if project is not None:
353
+ updates.append("project = ?")
354
+ params.append(project)
355
+ changes.append(f"project={project!r}")
356
+
357
+ if description is not None:
358
+ updates.append("description = ?")
359
+ params.append(description)
360
+ changes.append(f"description={description!r}")
361
+
362
+ if ssh_user is not None:
363
+ updates.append("ssh_user = ?")
364
+ params.append(ssh_user)
365
+ changes.append(f"ssh_user={ssh_user!r}")
366
+
367
+ if status is not None:
368
+ updates.append("status = ?")
369
+ params.append(status.value)
370
+ changes.append(f"status={status.value}")
371
+
372
+ if power_cycle_delay_seconds is not None:
373
+ updates.append("power_cycle_delay_seconds = ?")
374
+ # A negative sentinel (-0 too) clears the per-SBC override; -inf
375
+ # isn't one: it is refused with the other non-finite values.
376
+ value = power_cycle_delay_seconds
377
+ if (
378
+ value != -math.inf
379
+ and not math.isnan(value)
380
+ and math.copysign(1.0, value) < 0
381
+ ):
382
+ params.append(None)
383
+ changes.append("cycle_delay=cleared")
384
+ else:
385
+ from labctl.actuators.runtime import validate_cycle_delay
386
+
387
+ # Refuse what a power cycle would refuse: a stored delay is
388
+ # its default and floor, so it would block every cycle.
389
+ validate_cycle_delay(power_cycle_delay_seconds)
390
+ value = float(power_cycle_delay_seconds)
391
+ params.append(value)
392
+ changes.append(f"cycle_delay={value}s")
393
+
394
+ if updates:
395
+ updates.append("updated_at = CURRENT_TIMESTAMP")
396
+ sql = f"UPDATE sbcs SET {', '.join(updates)} WHERE id = ?"
397
+ params.append(sbc_id)
398
+ self.db.execute_modify(sql, tuple(params))
399
+ new_name = name if name else sbc.name
400
+ detail = f"Updated SBC {sbc.name}: " + ", ".join(changes)
401
+ self._audit_log("update", "sbc", sbc_id, new_name, detail)
402
+
403
+ return self.get_sbc(sbc_id)
404
+
405
+ def delete_sbc(self, sbc_id: int, force: bool = False) -> bool:
406
+ """
407
+ Delete SBC and all related records.
408
+
409
+ Args:
410
+ sbc_id: SBC database ID
411
+ force: If True, delete even when an active claim exists
412
+ (the claim rows cascade-delete with the SBC).
413
+
414
+ Returns:
415
+ True if deleted, False if not found
416
+
417
+ Raises:
418
+ ClaimConflict: If an active claim exists and force=False.
419
+ """
420
+ sbc = self.get_sbc(sbc_id)
421
+ if not sbc:
422
+ return False
423
+
424
+ if not force:
425
+ active = self.get_active_claim(sbc.name)
426
+ if active is not None:
427
+ raise ClaimConflict(active)
428
+
429
+ # Cascade delete handles related records (including claims)
430
+ count = self.db.execute_modify("DELETE FROM sbcs WHERE id = ?", (sbc_id,))
431
+ if count > 0:
432
+ self._audit_log(
433
+ "delete", "sbc", sbc_id, sbc.name, f"Deleted SBC: {sbc.name}"
434
+ )
435
+ return True
436
+
437
+ return False
438
+
439
+ # --- Serial Device Operations ---
440
+
441
+ def create_serial_device(
442
+ self,
443
+ name: str,
444
+ usb_path: str,
445
+ vendor: Optional[str] = None,
446
+ model: Optional[str] = None,
447
+ serial_number: Optional[str] = None,
448
+ ) -> SerialDevice:
449
+ """Register a USB-serial adapter."""
450
+ device_id = self.db.execute_insert(
451
+ """
452
+ INSERT INTO serial_devices (name, usb_path, vendor, model, serial_number)
453
+ VALUES (?, ?, ?, ?, ?)
454
+ """,
455
+ (name, usb_path, vendor, model, serial_number),
456
+ )
457
+ self._audit_log(
458
+ "create",
459
+ "serial_device",
460
+ device_id,
461
+ name,
462
+ f"Registered serial device: {name} ({usb_path})",
463
+ )
464
+ row = self.db.execute_one(
465
+ "SELECT * FROM serial_devices WHERE id = ?", (device_id,)
466
+ )
467
+ if not row:
468
+ raise RuntimeError(f"Failed to retrieve serial device {device_id}")
469
+ return SerialDevice.from_row(row)
470
+
471
+ def get_serial_device(self, device_id: int) -> Optional[SerialDevice]:
472
+ """Get serial device by ID."""
473
+ row = self.db.execute_one(
474
+ "SELECT * FROM serial_devices WHERE id = ?", (device_id,)
475
+ )
476
+ return SerialDevice.from_row(row) if row else None
477
+
478
+ def get_serial_device_by_name(self, name: str) -> Optional[SerialDevice]:
479
+ """Get serial device by name."""
480
+ row = self.db.execute_one(
481
+ "SELECT * FROM serial_devices WHERE name = ?", (name,)
482
+ )
483
+ return SerialDevice.from_row(row) if row else None
484
+
485
+ def list_serial_devices(self) -> list[SerialDevice]:
486
+ """List all registered serial devices."""
487
+ rows = self.db.execute("SELECT * FROM serial_devices ORDER BY name")
488
+ return [SerialDevice.from_row(r) for r in rows]
489
+
490
+ def rename_serial_device(
491
+ self, device_id: int, new_name: str
492
+ ) -> Optional[SerialDevice]:
493
+ """Rename a serial device."""
494
+ device = self.get_serial_device(device_id)
495
+ if not device:
496
+ return None
497
+ self.db.execute_modify(
498
+ "UPDATE serial_devices SET name = ? WHERE id = ?", (new_name, device_id)
499
+ )
500
+ self._audit_log(
501
+ "update",
502
+ "serial_device",
503
+ device_id,
504
+ new_name,
505
+ f"Renamed serial device: {device.name} -> {new_name}",
506
+ )
507
+ return self.get_serial_device(device_id)
508
+
509
+ def delete_serial_device(self, device_id: int) -> bool:
510
+ """Delete a serial device. Raises ValueError if still assigned to a port."""
511
+ device = self.get_serial_device(device_id)
512
+ if not device:
513
+ return False
514
+
515
+ # Check if in use
516
+ row = self.db.execute_one(
517
+ "SELECT id FROM serial_ports WHERE serial_device_id = ?", (device_id,)
518
+ )
519
+ if row:
520
+ raise ValueError(
521
+ f"Serial device '{device.name}' is still assigned to a port. "
522
+ "Remove the port assignment first."
523
+ )
524
+
525
+ count = self.db.execute_modify(
526
+ "DELETE FROM serial_devices WHERE id = ?", (device_id,)
527
+ )
528
+ if count > 0:
529
+ self._audit_log(
530
+ "delete",
531
+ "serial_device",
532
+ device_id,
533
+ device.name,
534
+ f"Deleted serial device: {device.name}",
535
+ )
536
+ return True
537
+ return False
538
+
539
+ # --- Serial Port Operations ---
540
+
541
+ def _resolve_serial_device_id(self, device_path: str) -> Optional[int]:
542
+ """Resolve a device_path like "/dev/lab/port-2-3" to a serial_device_id.
543
+
544
+ Returns None if the path doesn't match the /dev/lab/<name>
545
+ convention, or no registered device has that name.
546
+ """
547
+ # "/dev/lab/<name>" (normalised) and bare "<name>" (legacy rows),
548
+ # parsed as everywhere else (_lab_link_name).
549
+ name = self._lab_link_name(device_path)
550
+ if not name:
551
+ return None
552
+ row = self.db.execute_one(
553
+ "SELECT id FROM serial_devices WHERE name = ?", (name,)
554
+ )
555
+ return row["id"] if row else None
556
+
557
+ def repair_serial_port_links(self, apply: bool = False) -> list[dict]:
558
+ """Backfill NULL `serial_device_id` on serial_ports from device_path.
559
+
560
+ Args:
561
+ apply: If True, perform the UPDATE. Otherwise return the
562
+ planned changes without writing.
563
+
564
+ Returns:
565
+ List of dicts describing each repair: {port_id, sbc_id,
566
+ alias, device_path, resolved_device_id, resolved_name,
567
+ status}. Status is "repaired", "applied" (if apply=True and
568
+ write succeeded), "unresolvable" (no matching serial_device),
569
+ or "skipped" (FK was already set — included only if you
570
+ query via a broader scan; this method only returns rows it
571
+ had to consider).
572
+ """
573
+ rows = self.db.execute(
574
+ "SELECT id, sbc_id, alias, device_path, serial_device_id "
575
+ "FROM serial_ports WHERE serial_device_id IS NULL"
576
+ )
577
+ results: list[dict] = []
578
+ for r in rows:
579
+ resolved_id = self._resolve_serial_device_id(r["device_path"])
580
+ entry = {
581
+ "port_id": r["id"],
582
+ "sbc_id": r["sbc_id"],
583
+ "alias": r["alias"],
584
+ "device_path": r["device_path"],
585
+ "resolved_device_id": resolved_id,
586
+ "resolved_name": None,
587
+ "status": "unresolvable" if resolved_id is None else "repaired",
588
+ }
589
+ if resolved_id is not None:
590
+ name_row = self.db.execute_one(
591
+ "SELECT name FROM serial_devices WHERE id = ?",
592
+ (resolved_id,),
593
+ )
594
+ entry["resolved_name"] = name_row["name"] if name_row else None
595
+ if apply:
596
+ self.db.execute_modify(
597
+ "UPDATE serial_ports SET serial_device_id = ? WHERE id = ?",
598
+ (resolved_id, r["id"]),
599
+ )
600
+ entry["status"] = "applied"
601
+ results.append(entry)
602
+ return results
603
+
604
+ def assign_serial_port(
605
+ self,
606
+ sbc_id: int,
607
+ port_type: PortType,
608
+ device_path: str,
609
+ tcp_port: Optional[int] = None,
610
+ baud_rate: int = 115200,
611
+ alias: Optional[str] = None,
612
+ serial_device_id: Optional[int] = None,
613
+ ) -> SerialPort:
614
+ """
615
+ Assign a serial port to an SBC.
616
+
617
+ Args:
618
+ sbc_id: SBC ID
619
+ port_type: Type of port (console, jtag, debug)
620
+ device_path: Path to device (e.g., /dev/lab/port-1)
621
+ tcp_port: TCP port for ser2net (auto-assigned if None: one
622
+ above the highest port in use, and at least the manager's
623
+ base_tcp_port)
624
+ baud_rate: Baud rate (default: 115200)
625
+ alias: Human-friendly name for this assignment
626
+ serial_device_id: FK to serial_devices table
627
+ """
628
+ sbc = self.get_sbc(sbc_id)
629
+ if not sbc:
630
+ raise ValueError(f"SBC with ID {sbc_id} not found")
631
+
632
+ # Validate alias uniqueness
633
+ if alias:
634
+ existing = self.db.execute_one(
635
+ "SELECT id, sbc_id, port_type FROM serial_ports WHERE alias = ?",
636
+ (alias,),
637
+ )
638
+ if existing and (
639
+ existing["sbc_id"] != sbc_id or existing["port_type"] != port_type.value
640
+ ):
641
+ raise ValueError(f"Alias '{alias}' is already in use")
642
+
643
+ # Validate serial device exists
644
+ if serial_device_id:
645
+ dev = self.db.execute_one(
646
+ "SELECT id FROM serial_devices WHERE id = ?", (serial_device_id,)
647
+ )
648
+ if not dev:
649
+ raise ValueError(f"Serial device with ID {serial_device_id} not found")
650
+ else:
651
+ # Auto-resolve from device_path so callers that only provide a
652
+ # /dev/lab/<name> path (CLI without --serial-device, MCP tools,
653
+ # REST API) still populate the two-tier link. Without this, the
654
+ # port works at the device layer but `labctl serial list` shows
655
+ # the adapter as unassigned. Silently leave NULL if no match —
656
+ # paths outside /dev/lab (raw /dev/ttyUSB*, etc.) are legal.
657
+ resolved = self._resolve_serial_device_id(device_path)
658
+ if resolved is not None:
659
+ serial_device_id = resolved
660
+
661
+ # Auto-assign TCP port if not specified
662
+ if tcp_port is None:
663
+ tcp_port = self.next_tcp_port()
664
+ if not 1 <= tcp_port <= 65535:
665
+ # Re-assigning a port that holds the highest TCP port: keep it.
666
+ own = self.db.execute_one(
667
+ "SELECT tcp_port FROM serial_ports"
668
+ " WHERE sbc_id = ? AND port_type = ?",
669
+ (sbc_id, port_type.value),
670
+ )
671
+ if not (own and own["tcp_port"]):
672
+ raise ValueError(
673
+ f"No TCP port left to auto-assign (next would be "
674
+ f"{tcp_port}); pass one explicitly"
675
+ )
676
+ tcp_port = own["tcp_port"]
677
+ else:
678
+ # A whole number, or one written as text ("4000" from JSON or
679
+ # YAML); not a bool (YAML "yes") or a float.
680
+ if isinstance(tcp_port, str) and tcp_port.strip().isdigit():
681
+ tcp_port = int(tcp_port)
682
+ if isinstance(tcp_port, bool) or not isinstance(tcp_port, int):
683
+ raise ValueError(f"TCP port {tcp_port!r} is not a whole number")
684
+ if not 1 <= tcp_port <= 65535:
685
+ raise ValueError(f"TCP port {tcp_port} is out of range (1-65535)")
686
+ # ser2net serves one port per TCP port, and a board given another
687
+ # board's TCP port would reach that board's console past its
688
+ # claim (D013).
689
+ taken = self.db.execute_one(
690
+ "SELECT 1 FROM serial_ports WHERE tcp_port = ?"
691
+ " AND NOT (sbc_id = ? AND port_type = ?)",
692
+ (tcp_port, sbc_id, port_type.value),
693
+ )
694
+ if taken:
695
+ raise ValueError(f"TCP port {tcp_port} is already in use")
696
+
697
+ # Atomic upsert
698
+ port_id = self.db.execute_insert(
699
+ """
700
+ INSERT INTO serial_ports
701
+ (sbc_id, port_type, device_path, tcp_port, baud_rate, alias, serial_device_id)
702
+ VALUES (?, ?, ?, ?, ?, ?, ?)
703
+ ON CONFLICT (sbc_id, port_type) DO UPDATE SET
704
+ device_path = excluded.device_path,
705
+ tcp_port = excluded.tcp_port,
706
+ baud_rate = excluded.baud_rate,
707
+ alias = excluded.alias,
708
+ serial_device_id = excluded.serial_device_id
709
+ """, # noqa: E501
710
+ (
711
+ sbc_id,
712
+ port_type.value,
713
+ device_path,
714
+ tcp_port,
715
+ baud_rate,
716
+ alias,
717
+ serial_device_id,
718
+ ),
719
+ )
720
+
721
+ details = f"Assigned {port_type.value} port {device_path} to {sbc.name}"
722
+ if alias:
723
+ details += f" (alias: {alias})"
724
+ self._audit_log("assign", "serial_port", port_id, sbc.name, details)
725
+
726
+ row = self.db.execute_one(
727
+ "SELECT * FROM serial_ports WHERE sbc_id = ? AND port_type = ?",
728
+ (sbc_id, port_type.value),
729
+ )
730
+ if not row:
731
+ raise RuntimeError(f"Failed to retrieve serial port for {sbc.name}")
732
+ return SerialPort.from_row(row)
733
+
734
+ def remove_serial_port(self, sbc_id: int, port_type: PortType) -> bool:
735
+ """Remove a serial port assignment."""
736
+ sbc = self.get_sbc(sbc_id)
737
+ if not sbc:
738
+ return False
739
+
740
+ count = self.db.execute_modify(
741
+ "DELETE FROM serial_ports WHERE sbc_id = ? AND port_type = ?",
742
+ (sbc_id, port_type.value),
743
+ )
744
+
745
+ if count > 0:
746
+ self._audit_log(
747
+ "remove",
748
+ "serial_port",
749
+ None,
750
+ sbc.name,
751
+ f"Removed {port_type.value} port from {sbc.name}",
752
+ )
753
+ return True
754
+
755
+ return False
756
+
757
+ def get_serial_port(self, sbc_id: int, port_type: PortType) -> Optional[SerialPort]:
758
+ """The SBC's port of this type, or None."""
759
+ row = self.db.execute_one(
760
+ "SELECT * FROM serial_ports WHERE sbc_id = ? AND port_type = ?",
761
+ (sbc_id, port_type.value),
762
+ )
763
+ if not row:
764
+ return None
765
+ port = SerialPort.from_row(row)
766
+ self._load_serial_device(port)
767
+ return port
768
+
769
+ def list_serial_ports(self) -> list[SerialPort]:
770
+ """List all serial port assignments."""
771
+ rows = self.db.execute("SELECT * FROM serial_ports ORDER BY sbc_id, port_type")
772
+ ports = [SerialPort.from_row(r) for r in rows]
773
+ for port in ports:
774
+ self._load_serial_device(port)
775
+ return ports
776
+
777
+ def get_serial_port_by_alias(self, alias: str) -> Optional[SerialPort]:
778
+ """Get a serial port assignment by its alias."""
779
+ row = self.db.execute_one(
780
+ "SELECT * FROM serial_ports WHERE alias = ?", (alias,)
781
+ )
782
+ if not row:
783
+ return None
784
+ port = SerialPort.from_row(row)
785
+ self._load_serial_device(port)
786
+ return port
787
+
788
+ def next_tcp_port(self) -> int:
789
+ """Next TCP port to auto-assign: one above the highest in use, and
790
+ never below ``base_tcp_port``."""
791
+ base_port = self.base_tcp_port
792
+ row = self.db.execute_one("SELECT MAX(tcp_port) as max_port FROM serial_ports")
793
+ if row and row["max_port"]:
794
+ return max(row["max_port"] + 1, base_port)
795
+ return base_port
796
+
797
+ # --- Network Address Operations ---
798
+
799
+ def set_network_address(
800
+ self,
801
+ sbc_id: int,
802
+ address_type: AddressType,
803
+ ip_address: str,
804
+ mac_address: Optional[str] = None,
805
+ hostname: Optional[str] = None,
806
+ ) -> NetworkAddress:
807
+ """Set network address for an SBC."""
808
+ sbc = self.get_sbc(sbc_id)
809
+ if not sbc:
810
+ raise ValueError(f"SBC with ID {sbc_id} not found")
811
+
812
+ # Atomic upsert
813
+ addr_id = self.db.execute_insert(
814
+ """
815
+ INSERT INTO network_addresses
816
+ (sbc_id, address_type, ip_address, mac_address, hostname)
817
+ VALUES (?, ?, ?, ?, ?)
818
+ ON CONFLICT (sbc_id, address_type) DO UPDATE SET
819
+ ip_address = excluded.ip_address,
820
+ mac_address = excluded.mac_address,
821
+ hostname = excluded.hostname
822
+ """,
823
+ (sbc_id, address_type.value, ip_address, mac_address, hostname),
824
+ )
825
+
826
+ self._audit_log(
827
+ "set",
828
+ "network_address",
829
+ addr_id,
830
+ sbc.name,
831
+ f"Set {address_type.value} address {ip_address} for {sbc.name}",
832
+ )
833
+
834
+ row = self.db.execute_one(
835
+ "SELECT * FROM network_addresses WHERE sbc_id = ? AND address_type = ?",
836
+ (sbc_id, address_type.value),
837
+ )
838
+ if not row:
839
+ raise RuntimeError(f"Failed to retrieve network address for {sbc.name}")
840
+ return NetworkAddress.from_row(row)
841
+
842
+ def remove_network_address(self, sbc_id: int, address_type: AddressType) -> bool:
843
+ """Remove network address from an SBC."""
844
+ count = self.db.execute_modify(
845
+ "DELETE FROM network_addresses WHERE sbc_id = ? AND address_type = ?",
846
+ (sbc_id, address_type.value),
847
+ )
848
+ return count > 0
849
+
850
+ # --- Power Plug Operations ---
851
+
852
+ def assign_power_plug(
853
+ self,
854
+ sbc_id: int,
855
+ plug_type: PlugType,
856
+ address: str,
857
+ plug_index: int = 1,
858
+ ) -> PowerPlug:
859
+ """Assign a power plug to an SBC."""
860
+ sbc = self.get_sbc(sbc_id)
861
+ if not sbc:
862
+ raise ValueError(f"SBC with ID {sbc_id} not found")
863
+
864
+ # Atomic upsert
865
+ plug_id = self.db.execute_insert(
866
+ """
867
+ INSERT INTO power_plugs (sbc_id, plug_type, address, plug_index)
868
+ VALUES (?, ?, ?, ?)
869
+ ON CONFLICT (sbc_id) DO UPDATE SET
870
+ plug_type = excluded.plug_type,
871
+ address = excluded.address,
872
+ plug_index = excluded.plug_index
873
+ """,
874
+ (sbc_id, plug_type.value, address, plug_index),
875
+ )
876
+
877
+ self._audit_log(
878
+ "assign",
879
+ "power_plug",
880
+ plug_id,
881
+ sbc.name,
882
+ f"Assigned {plug_type.value} plug {address} to {sbc.name}",
883
+ )
884
+
885
+ row = self.db.execute_one(
886
+ "SELECT * FROM power_plugs WHERE sbc_id = ?", (sbc_id,)
887
+ )
888
+ if not row:
889
+ raise RuntimeError(f"Failed to retrieve power plug for {sbc.name}")
890
+ return PowerPlug.from_row(row)
891
+
892
+ def remove_power_plug(self, sbc_id: int) -> bool:
893
+ """Remove power plug assignment from an SBC."""
894
+ count = self.db.execute_modify(
895
+ "DELETE FROM power_plugs WHERE sbc_id = ?", (sbc_id,)
896
+ )
897
+ return count > 0
898
+
899
+ # --- SDWire Device Operations ---
900
+
901
+ def create_sdwire_device(
902
+ self,
903
+ name: str,
904
+ serial_number: str,
905
+ device_type: str = "sdwirec",
906
+ ) -> SDWireDevice:
907
+ """Register an SDWire SD card multiplexer device."""
908
+ device_id = self.db.execute_insert(
909
+ """INSERT INTO sdwire_devices (name, serial_number, device_type)
910
+ VALUES (?, ?, ?)""",
911
+ (name, serial_number, device_type),
912
+ )
913
+ self._audit_log(
914
+ "create",
915
+ "sdwire_device",
916
+ device_id,
917
+ name,
918
+ f"Registered SDWire device: {name} ({serial_number})",
919
+ )
920
+ row = self.db.execute_one(
921
+ "SELECT * FROM sdwire_devices WHERE id = ?", (device_id,)
922
+ )
923
+ if not row:
924
+ raise RuntimeError(f"Failed to retrieve SDWire device {device_id}")
925
+ return SDWireDevice.from_row(row)
926
+
927
+ def get_sdwire_device(self, device_id: int) -> Optional[SDWireDevice]:
928
+ """Get SDWire device by ID."""
929
+ row = self.db.execute_one(
930
+ "SELECT * FROM sdwire_devices WHERE id = ?", (device_id,)
931
+ )
932
+ return SDWireDevice.from_row(row) if row else None
933
+
934
+ def get_sdwire_device_by_name(self, name: str) -> Optional[SDWireDevice]:
935
+ """Get SDWire device by name."""
936
+ row = self.db.execute_one(
937
+ "SELECT * FROM sdwire_devices WHERE name = ?", (name,)
938
+ )
939
+ return SDWireDevice.from_row(row) if row else None
940
+
941
+ def list_sdwire_devices(self) -> list[SDWireDevice]:
942
+ """List all registered SDWire devices."""
943
+ rows = self.db.execute("SELECT * FROM sdwire_devices ORDER BY name")
944
+ return [SDWireDevice.from_row(r) for r in rows]
945
+
946
+ def delete_sdwire_device(self, device_id: int) -> bool:
947
+ """Delete an SDWire device. Raises ValueError if still assigned."""
948
+ device = self.get_sdwire_device(device_id)
949
+ if not device:
950
+ return False
951
+
952
+ row = self.db.execute_one(
953
+ "SELECT id FROM sdwire_assignments WHERE sdwire_device_id = ?", (device_id,)
954
+ )
955
+ if row:
956
+ raise ValueError(
957
+ f"SDWire device '{device.name}' is still assigned to an SBC. "
958
+ "Unassign it first."
959
+ )
960
+
961
+ count = self.db.execute_modify(
962
+ "DELETE FROM sdwire_devices WHERE id = ?", (device_id,)
963
+ )
964
+ if count > 0:
965
+ self._audit_log(
966
+ "delete",
967
+ "sdwire_device",
968
+ device_id,
969
+ device.name,
970
+ f"Deleted SDWire device: {device.name}",
971
+ )
972
+ return True
973
+ return False
974
+
975
+ def sbcs_sharing_hardware(
976
+ self,
977
+ *,
978
+ plug: Optional[tuple[str, str, int]] = None,
979
+ sdwire_device_id: Optional[int] = None,
980
+ serial_device_path: Optional[str] = None,
981
+ serial_device_id: Optional[int] = None,
982
+ tcp_port: Optional[int] = None,
983
+ ) -> list[str]:
984
+ """Names of SBCs already wired to this plug outlet (type, address,
985
+ index), SDWire device or serial device path. Nothing in the schema
986
+ stops two boards sharing one, so a claim check on a new assignment
987
+ has to cover the boards that have it already (D013)."""
988
+ names: set[str] = set()
989
+ if plug is not None:
990
+ wanted = outlet_key(*plug)
991
+ # SQL narrows to the plug type only: SQLite's trim()/lower()
992
+ # don't match str.strip()/lower() (tabs, newlines, non-ASCII),
993
+ # so the address is compared through outlet_key in Python.
994
+ rows = self.db.execute(
995
+ "SELECT s.name, p.plug_type, p.address, p.plug_index"
996
+ " FROM power_plugs p JOIN sbcs s ON s.id = p.sbc_id"
997
+ " WHERE p.plug_type = ?",
998
+ (wanted[0],),
999
+ )
1000
+ names.update(
1001
+ r["name"]
1002
+ for r in rows
1003
+ if outlets_overlap(
1004
+ wanted, outlet_key(r["plug_type"], r["address"], r["plug_index"])
1005
+ )
1006
+ )
1007
+ if sdwire_device_id is not None:
1008
+ rows = self.db.execute(
1009
+ "SELECT s.name FROM sdwire_assignments a"
1010
+ " JOIN sbcs s ON s.id = a.sbc_id WHERE a.sdwire_device_id = ?",
1011
+ (sdwire_device_id,),
1012
+ )
1013
+ names.update(r["name"] for r in rows)
1014
+ if serial_device_path is not None or serial_device_id is not None:
1015
+ wanted = self._serial_identity(serial_device_path, serial_device_id)
1016
+ rows = self.db.execute(
1017
+ "SELECT s.name, p.device_path, p.serial_device_id"
1018
+ " FROM serial_ports p JOIN sbcs s ON s.id = p.sbc_id"
1019
+ )
1020
+ names.update(
1021
+ r["name"]
1022
+ for r in rows
1023
+ if wanted
1024
+ & self._serial_identity(r["device_path"], r["serial_device_id"])
1025
+ )
1026
+ if tcp_port is not None:
1027
+ # One ser2net TCP port is one console, whatever the device path
1028
+ # says (rows saved before assignment refused duplicates).
1029
+ rows = self.db.execute(
1030
+ "SELECT s.name FROM serial_ports p JOIN sbcs s ON s.id = p.sbc_id"
1031
+ " WHERE p.tcp_port = ?",
1032
+ (tcp_port,),
1033
+ )
1034
+ names.update(r["name"] for r in rows)
1035
+ return sorted(names)
1036
+
1037
+ def sbcs_sharing_hardware_with(self, sbc_name: str) -> list[str]:
1038
+ """Other SBCs wired to any of ``sbc_name``'s plug outlet, SDWire,
1039
+ serial devices or relay channels: acting on one acts on the other,
1040
+ so a claim on either covers both (D013)."""
1041
+ sbc = self.get_sbc_by_name(sbc_name)
1042
+ if sbc is None:
1043
+ return []
1044
+ names: set[str] = set()
1045
+ if sbc.power_plug is not None:
1046
+ plug = sbc.power_plug
1047
+ names.update(
1048
+ self.sbcs_sharing_hardware(
1049
+ plug=(plug.plug_type.value, plug.address, plug.plug_index)
1050
+ )
1051
+ )
1052
+ row = self.db.execute_one(
1053
+ "SELECT sdwire_device_id FROM sdwire_assignments WHERE sbc_id = ?",
1054
+ (sbc.id,),
1055
+ )
1056
+ if row:
1057
+ names.update(
1058
+ self.sbcs_sharing_hardware(sdwire_device_id=row["sdwire_device_id"])
1059
+ )
1060
+ for port in sbc.serial_ports:
1061
+ names.update(
1062
+ self.sbcs_sharing_hardware(
1063
+ serial_device_path=port.device_path,
1064
+ serial_device_id=port.serial_device_id,
1065
+ tcp_port=port.tcp_port,
1066
+ )
1067
+ )
1068
+ channel_ids = {b.actuator_channel_id for b in self.list_bindings(sbc_id=sbc.id)}
1069
+ if channel_ids:
1070
+ for b in self.list_bindings(
1071
+ channel_ids=self.channels_on_same_device(channel_ids)
1072
+ ):
1073
+ names.add(b.sbc_name)
1074
+ names.discard(sbc_name)
1075
+ names.discard(None)
1076
+ return sorted(names)
1077
+
1078
+ def sbcs_using_serial_links(self, link_names: Optional[Iterable[str]]) -> list[str]:
1079
+ """Names of SBCs with a serial port that reaches its adapter through
1080
+ one of these ``/dev/lab/<name>`` links (None: any link): assigned by
1081
+ that path (or a bare legacy ``<name>``), or linked to the registered
1082
+ adapter of that name. A port assigned by another path
1083
+ (``/dev/ttyUSB0``, ``/dev/serial/by-id/...``) doesn't use a link."""
1084
+ wanted = None if link_names is None else set(link_names)
1085
+ if wanted is not None and not wanted:
1086
+ return []
1087
+ rows = self.db.execute(
1088
+ "SELECT s.name, p.device_path, d.name AS device_name"
1089
+ " FROM serial_ports p JOIN sbcs s ON s.id = p.sbc_id"
1090
+ " LEFT JOIN serial_devices d ON d.id = p.serial_device_id"
1091
+ )
1092
+ names: set[str] = set()
1093
+ for r in rows:
1094
+ link = self._lab_link_name(r["device_path"])
1095
+ used = {n for n in (link, r["device_name"]) if n}
1096
+ if used and (wanted is None or used & wanted):
1097
+ names.add(r["name"])
1098
+ return sorted(names)
1099
+
1100
+ @staticmethod
1101
+ def _lab_link_name(device_path: Optional[str]) -> Optional[str]:
1102
+ """The ``<name>`` of a ``/dev/lab/<name>`` path (normalised first,
1103
+ so ``/dev//lab/./x`` and ``//dev/lab/x`` count), a bare legacy
1104
+ ``<name>``, or None for any other path."""
1105
+ if not device_path:
1106
+ return None
1107
+ if "/" not in device_path:
1108
+ return device_path
1109
+ if not device_path.startswith("/"):
1110
+ return None
1111
+ path = "/" + os.path.normpath(device_path).lstrip("/")
1112
+ prefix = "/dev/lab/"
1113
+ if path.startswith(prefix) and "/" not in path[len(prefix) :]:
1114
+ return path[len(prefix) :] or None
1115
+ return None
1116
+
1117
+ def _serial_identity(
1118
+ self, device_path: Optional[str], serial_device_id: Optional[int]
1119
+ ) -> set:
1120
+ """Keys naming a serial adapter: its registered device (given, or
1121
+ from a ``/dev/lab/<name>`` path) and its path with symlinks resolved
1122
+ (``/dev/lab/port-1``, ``/dev/ttyUSB0`` and ``/dev/serial/by-id/...``
1123
+ for one adapter all resolve to the same node)."""
1124
+ keys: set = set()
1125
+ if serial_device_id is None and device_path:
1126
+ serial_device_id = self._resolve_serial_device_id(device_path)
1127
+ if serial_device_id is not None:
1128
+ keys.add(("device", serial_device_id))
1129
+ if device_path:
1130
+ keys.add(("path", os.path.realpath(device_path)))
1131
+ return keys
1132
+
1133
+ def channels_on_same_device(self, channel_ids: Iterable[int]) -> set[int]:
1134
+ """``channel_ids`` plus the same-index channels of any other
1135
+ actuator record for the same relay board (nothing stops two records
1136
+ naming one). Records match by device path after resolving symlinks
1137
+ (``/dev/lab/relay-a`` and the ``/dev/ttyUSB0`` it points to), or by
1138
+ USB serial number with VID/PID. Records identified only by VID/PID can't be told
1139
+ apart from other boards of the same model and aren't matched."""
1140
+ ids = set(channel_ids)
1141
+ if not ids:
1142
+ return ids
1143
+ by_channel = {
1144
+ c.id: (a, c.channel_index)
1145
+ for a in self.list_actuators()
1146
+ for c in a.channels
1147
+ }
1148
+
1149
+ def identity(actuator) -> set:
1150
+ keys = set()
1151
+ if actuator.device_path:
1152
+ keys.add(("path", os.path.realpath(actuator.device_path)))
1153
+ # Cheap USB-serial chips often share placeholder serial
1154
+ # numbers, so a serial number only identifies a board together
1155
+ # with its VID/PID.
1156
+ if actuator.serial_no and actuator.vid and actuator.pid:
1157
+ keys.add(
1158
+ (
1159
+ "serial",
1160
+ _norm_usb_id(actuator.vid),
1161
+ _norm_usb_id(actuator.pid),
1162
+ actuator.serial_no.strip(),
1163
+ )
1164
+ )
1165
+ return keys
1166
+
1167
+ # Repeat until stable: a record may share its path with one record
1168
+ # and its serial number with another.
1169
+ while True:
1170
+ wanted = {
1171
+ (key, by_channel[cid][1])
1172
+ for cid in ids
1173
+ if cid in by_channel
1174
+ for key in identity(by_channel[cid][0])
1175
+ }
1176
+ found = {
1177
+ cid
1178
+ for cid, (actuator, index) in by_channel.items()
1179
+ if any((key, index) in wanted for key in identity(actuator))
1180
+ }
1181
+ if found <= ids:
1182
+ return ids
1183
+ ids |= found
1184
+
1185
+ def assign_sdwire(self, sbc_id: int, sdwire_device_id: int) -> None:
1186
+ """Assign an SDWire device to an SBC."""
1187
+ sbc = self.get_sbc(sbc_id)
1188
+ if not sbc:
1189
+ raise ValueError(f"SBC with ID {sbc_id} not found")
1190
+
1191
+ device = self.get_sdwire_device(sdwire_device_id)
1192
+ if not device:
1193
+ raise ValueError(f"SDWire device with ID {sdwire_device_id} not found")
1194
+
1195
+ # Atomic upsert
1196
+ self.db.execute_insert(
1197
+ """INSERT INTO sdwire_assignments (sbc_id, sdwire_device_id) VALUES (?, ?)
1198
+ ON CONFLICT (sbc_id) DO UPDATE SET sdwire_device_id = excluded.sdwire_device_id""", # noqa: E501
1199
+ (sbc_id, sdwire_device_id),
1200
+ )
1201
+
1202
+ self._audit_log(
1203
+ "assign",
1204
+ "sdwire",
1205
+ sdwire_device_id,
1206
+ sbc.name,
1207
+ f"Assigned SDWire '{device.name}' to {sbc.name}",
1208
+ )
1209
+
1210
+ def unassign_sdwire(self, sbc_id: int) -> bool:
1211
+ """Remove SDWire assignment from an SBC."""
1212
+ count = self.db.execute_modify(
1213
+ "DELETE FROM sdwire_assignments WHERE sbc_id = ?", (sbc_id,)
1214
+ )
1215
+ return count > 0
1216
+
1217
+ # --- Status Log ---
1218
+
1219
+ def update_power_observation(
1220
+ self,
1221
+ sbc_id: int,
1222
+ power_state: Optional[str],
1223
+ ) -> None:
1224
+ """Record the daemon's most recent power observation for an SBC.
1225
+
1226
+ Stamps `last_power_at` with the current time even when the
1227
+ observed state hasn't changed, so `status --fast` can report
1228
+ freshness. A `power_state` of None records "unknown" so callers
1229
+ can distinguish "no plug / never observed" (NULL in the column)
1230
+ from "observed but indeterminate".
1231
+ """
1232
+ normalized = (power_state or "unknown").lower()
1233
+ if normalized not in {"on", "off", "unknown"}:
1234
+ normalized = "unknown"
1235
+ self.db.execute_modify(
1236
+ """
1237
+ UPDATE sbcs
1238
+ SET last_power_state = ?,
1239
+ last_power_at = CURRENT_TIMESTAMP
1240
+ WHERE id = ?
1241
+ """,
1242
+ (normalized, sbc_id),
1243
+ )
1244
+
1245
+ def log_status(
1246
+ self,
1247
+ sbc_id: int,
1248
+ status: Status,
1249
+ details: Optional[str] = None,
1250
+ ) -> int:
1251
+ """
1252
+ Log a status change to the status_log table.
1253
+
1254
+ Args:
1255
+ sbc_id: ID of the SBC
1256
+ status: New status value
1257
+ details: Optional details about the status change
1258
+
1259
+ Returns:
1260
+ ID of the inserted log entry
1261
+ """
1262
+ return self.db.execute_insert(
1263
+ """
1264
+ INSERT INTO status_log (sbc_id, status, details)
1265
+ VALUES (?, ?, ?)
1266
+ """,
1267
+ (sbc_id, status.value, details),
1268
+ )
1269
+
1270
+ def get_status_history(
1271
+ self,
1272
+ sbc_id: Optional[int] = None,
1273
+ limit: int = 100,
1274
+ ) -> list[dict]:
1275
+ """
1276
+ Get status history for an SBC or all SBCs.
1277
+
1278
+ Args:
1279
+ sbc_id: Optional SBC ID to filter by
1280
+ limit: Maximum number of entries to return
1281
+
1282
+ Returns:
1283
+ List of status log entries as dictionaries
1284
+ """
1285
+ if sbc_id:
1286
+ rows = self.db.execute(
1287
+ """
1288
+ SELECT sl.id, sl.sbc_id, s.name as sbc_name, sl.status,
1289
+ sl.details, sl.logged_at
1290
+ FROM status_log sl
1291
+ JOIN sbcs s ON sl.sbc_id = s.id
1292
+ WHERE sl.sbc_id = ?
1293
+ ORDER BY sl.logged_at DESC
1294
+ LIMIT ?
1295
+ """,
1296
+ (sbc_id, limit),
1297
+ )
1298
+ else:
1299
+ rows = self.db.execute(
1300
+ """
1301
+ SELECT sl.id, sl.sbc_id, s.name as sbc_name, sl.status,
1302
+ sl.details, sl.logged_at
1303
+ FROM status_log sl
1304
+ JOIN sbcs s ON sl.sbc_id = s.id
1305
+ ORDER BY sl.logged_at DESC
1306
+ LIMIT ?
1307
+ """,
1308
+ (limit,),
1309
+ )
1310
+
1311
+ return [
1312
+ {
1313
+ "id": row["id"],
1314
+ "sbc_id": row["sbc_id"],
1315
+ "sbc_name": row["sbc_name"],
1316
+ "status": row["status"],
1317
+ "details": row["details"],
1318
+ "logged_at": row["logged_at"],
1319
+ }
1320
+ for row in rows
1321
+ ]
1322
+
1323
+ # Rows one prune transaction deletes at most: the first prune after an
1324
+ # upgrade may have years of history, and each batch holds the write
1325
+ # lock only briefly.
1326
+ PRUNE_BATCH = 5000
1327
+
1328
+ def cleanup_old_status_logs(
1329
+ self, retention_days: int, *, batch_size: Optional[int] = None
1330
+ ) -> int:
1331
+ """
1332
+ Delete status log entries older than retention period.
1333
+
1334
+ Rows that readers still need are kept whatever their age: for each
1335
+ SBC, the newest row of each status (``get_uptime``'s "last online"
1336
+ and "last offline", so a board stable for longer than the retention
1337
+ keeps its uptime) and the newest row before the cutoff (the status
1338
+ in effect when the kept history starts, which the 24-hour uptime
1339
+ figure starts from).
1340
+
1341
+ Rows are deleted in transactions of at most ``batch_size``
1342
+ (default PRUNE_BATCH), the keep rules applied afresh each time.
1343
+
1344
+ Args:
1345
+ retention_days: Number of days to retain status logs
1346
+ batch_size: Rows per delete transaction
1347
+
1348
+ Returns:
1349
+ Number of deleted entries
1350
+ """
1351
+ cutoff = f"-{int(retention_days)} days"
1352
+ batch = batch_size or self.PRUNE_BATCH
1353
+ # The monitor calls this hourly, and the rows always kept are past
1354
+ # the cutoff, so "anything past the cutoff?" is nearly always yes.
1355
+ # Instead, remember the table's state after the last prune with
1356
+ # this retention: the newest row id, the number of rows past the
1357
+ # cutoff (an index count) and the total. If none changed, nothing
1358
+ # can have become deletable: no row has aged past the cutoff, no
1359
+ # new row has replaced a kept one as its board's newest for its
1360
+ # status, and no rows were deleted meanwhile (e.g. with a removed
1361
+ # board) that ageing could have made up for. The DELETE is skipped.
1362
+ state = self._status_log_state(cutoff)
1363
+ if state[1] == 0 or (retention_days, state) == self._prune_state:
1364
+ self._prune_state = (retention_days, state)
1365
+ return 0
1366
+ deleted = 0
1367
+ while True:
1368
+ # A plain DELETE (not WITH ... DELETE): sqlite3 reports
1369
+ # rowcount only for statements that start with DELETE.
1370
+ count = self.db.execute_modify(
1371
+ """
1372
+ DELETE FROM status_log WHERE id IN (
1373
+ SELECT id FROM status_log
1374
+ WHERE logged_at < datetime('now', ?)
1375
+ AND id NOT IN (
1376
+ SELECT id FROM (
1377
+ SELECT id, ROW_NUMBER() OVER (
1378
+ PARTITION BY sbc_id, status
1379
+ ORDER BY logged_at DESC, id DESC
1380
+ ) AS rn
1381
+ FROM status_log
1382
+ ) WHERE rn = 1
1383
+ UNION
1384
+ SELECT id FROM (
1385
+ SELECT id, ROW_NUMBER() OVER (
1386
+ PARTITION BY sbc_id ORDER BY logged_at DESC, id DESC
1387
+ ) AS rn
1388
+ FROM status_log
1389
+ WHERE logged_at < datetime('now', ?)
1390
+ ) WHERE rn = 1
1391
+ )
1392
+ ORDER BY logged_at, id
1393
+ LIMIT ?
1394
+ )
1395
+ """,
1396
+ (cutoff, cutoff, batch),
1397
+ )
1398
+ deleted += count
1399
+ if count < batch:
1400
+ break
1401
+ self._prune_state = (retention_days, self._status_log_state(cutoff))
1402
+ return deleted
1403
+
1404
+ def _status_log_state(self, cutoff: str) -> tuple:
1405
+ """(newest id, rows older than ``cutoff``, all rows)."""
1406
+ row = self.db.execute_one(
1407
+ "SELECT (SELECT MAX(id) FROM status_log) AS top,"
1408
+ " (SELECT COUNT(*) FROM status_log"
1409
+ " WHERE logged_at < datetime('now', ?)) AS old,"
1410
+ " (SELECT COUNT(*) FROM status_log) AS total",
1411
+ (cutoff,),
1412
+ )
1413
+ return (row["top"], row["old"], row["total"])
1414
+
1415
+ def get_uptime(self, sbc_id: int) -> Optional[dict]:
1416
+ """
1417
+ Calculate uptime statistics for an SBC.
1418
+
1419
+ Calculates the current uptime (time since last transition to online)
1420
+ and total uptime over the last 24 hours.
1421
+
1422
+ Args:
1423
+ sbc_id: ID of the SBC
1424
+
1425
+ Returns:
1426
+ Dictionary with uptime statistics, or None if no history
1427
+ """
1428
+ from datetime import datetime, timedelta
1429
+
1430
+ # status_log.logged_at is SQLite's CURRENT_TIMESTAMP: UTC, as
1431
+ # "YYYY-MM-DD HH:MM:SS". Compute in the same clock and format.
1432
+ def parse(ts: str) -> datetime:
1433
+ return datetime.fromisoformat(ts)
1434
+
1435
+ # Get last online transition
1436
+ last_online_row = self.db.execute_one(
1437
+ """
1438
+ SELECT id, logged_at FROM status_log
1439
+ WHERE sbc_id = ? AND status = 'online'
1440
+ ORDER BY logged_at DESC, id DESC
1441
+ LIMIT 1
1442
+ """,
1443
+ (sbc_id,),
1444
+ )
1445
+
1446
+ # Get last offline transition
1447
+ last_offline_row = self.db.execute_one(
1448
+ """
1449
+ SELECT id, logged_at FROM status_log
1450
+ WHERE sbc_id = ? AND status IN ('offline', 'error')
1451
+ ORDER BY logged_at DESC, id DESC
1452
+ LIMIT 1
1453
+ """,
1454
+ (sbc_id,),
1455
+ )
1456
+
1457
+ # Get current SBC status
1458
+ sbc = self.get_sbc(sbc_id)
1459
+ if not sbc:
1460
+ return None
1461
+
1462
+ result = {
1463
+ "sbc_id": sbc_id,
1464
+ "sbc_name": sbc.name,
1465
+ "current_status": sbc.status.value,
1466
+ "current_uptime_seconds": 0,
1467
+ "current_uptime_formatted": "0s",
1468
+ "uptime_24h_percent": 0.0,
1469
+ }
1470
+
1471
+ now = parse(self.db.execute_one("SELECT datetime('now') AS t")["t"])
1472
+
1473
+ # Calculate current uptime if online
1474
+ if sbc.status == Status.ONLINE and last_online_row:
1475
+ last_online = parse(last_online_row["logged_at"])
1476
+ # Check if we went offline after going online
1477
+ if last_offline_row:
1478
+ last_offline = parse(last_offline_row["logged_at"])
1479
+ # Same second (CURRENT_TIMESTAMP's resolution): the row
1480
+ # logged later, i.e. with the higher id, is the newer one.
1481
+ offline_is_newer = (last_offline, last_offline_row["id"]) > (
1482
+ last_online,
1483
+ last_online_row["id"],
1484
+ )
1485
+ if offline_is_newer:
1486
+ # We're currently offline
1487
+ result["current_uptime_seconds"] = 0
1488
+ else:
1489
+ # We're online since last_online
1490
+ uptime = (now - last_online).total_seconds()
1491
+ result["current_uptime_seconds"] = int(uptime)
1492
+ else:
1493
+ # Never went offline
1494
+ uptime = (now - last_online).total_seconds()
1495
+ result["current_uptime_seconds"] = int(uptime)
1496
+
1497
+ result["current_uptime_formatted"] = self._format_duration(
1498
+ result["current_uptime_seconds"]
1499
+ )
1500
+
1501
+ # Calculate 24h uptime percentage, starting from the status in
1502
+ # effect 24 hours ago (the newest row before then), so a board that
1503
+ # has been stable for longer counts as up or down the whole time.
1504
+ day_ago = now - timedelta(hours=24)
1505
+ day_ago_text = day_ago.strftime("%Y-%m-%d %H:%M:%S")
1506
+ before = self.db.execute_one(
1507
+ """
1508
+ SELECT status FROM status_log
1509
+ WHERE sbc_id = ? AND logged_at < ?
1510
+ ORDER BY logged_at DESC, id DESC
1511
+ LIMIT 1
1512
+ """,
1513
+ (sbc_id, day_ago_text),
1514
+ )
1515
+ rows = self.db.execute(
1516
+ """
1517
+ SELECT status, logged_at FROM status_log
1518
+ WHERE sbc_id = ? AND logged_at >= ?
1519
+ ORDER BY logged_at ASC, id ASC
1520
+ """,
1521
+ (sbc_id, day_ago_text),
1522
+ )
1523
+
1524
+ if rows or before:
1525
+ online_seconds = 0
1526
+ prev_status = before["status"] if before else None
1527
+ prev_time = day_ago
1528
+
1529
+ for row in rows:
1530
+ status = row["status"]
1531
+ time_str = row["logged_at"]
1532
+ log_time = parse(time_str)
1533
+
1534
+ if prev_status == "online":
1535
+ online_seconds += (log_time - prev_time).total_seconds()
1536
+
1537
+ prev_status = status
1538
+ prev_time = log_time
1539
+
1540
+ # Account for current status until now
1541
+ if prev_status == "online":
1542
+ online_seconds += (now - prev_time).total_seconds()
1543
+
1544
+ total_seconds = 24 * 60 * 60
1545
+ result["uptime_24h_percent"] = round(
1546
+ (online_seconds / total_seconds) * 100, 2
1547
+ )
1548
+
1549
+ return result
1550
+
1551
+ def _format_duration(self, seconds: int) -> str:
1552
+ """Format duration in seconds to human-readable string."""
1553
+ if seconds < 60:
1554
+ return f"{seconds}s"
1555
+ elif seconds < 3600:
1556
+ m = seconds // 60
1557
+ s = seconds % 60
1558
+ return f"{m}m {s}s"
1559
+ elif seconds < 86400:
1560
+ h = seconds // 3600
1561
+ m = (seconds % 3600) // 60
1562
+ return f"{h}h {m}m"
1563
+ else:
1564
+ d = seconds // 86400
1565
+ h = (seconds % 86400) // 3600
1566
+ return f"{d}d {h}h"
1567
+
1568
+ # --- Claim Operations ---
1569
+
1570
+ # Microsecond precision so short-duration expiry comparisons don't
1571
+ # collapse to a single second. SQLite stores these as TEXT and the
1572
+ # ISO-8601-ish layout compares lexically in the same order as datetimes.
1573
+ _TS_FMT = "%Y-%m-%d %H:%M:%S.%f"
1574
+
1575
+ @classmethod
1576
+ def _fmt_ts(cls, dt: datetime) -> str:
1577
+ return dt.strftime(cls._TS_FMT)
1578
+
1579
+ def _load_pending_requests(self, claim: Claim) -> None:
1580
+ rows = self.db.execute(
1581
+ """
1582
+ SELECT * FROM claim_requests
1583
+ WHERE claim_id = ? AND acknowledged = 0
1584
+ ORDER BY requested_at ASC
1585
+ """,
1586
+ (claim.id,),
1587
+ )
1588
+ claim.pending_requests = [ClaimRequest.from_row(r) for r in rows]
1589
+
1590
+ def _require_sbc_id(self, sbc_name: str) -> int:
1591
+ sbc = self.get_sbc_by_name(sbc_name)
1592
+ if sbc is None:
1593
+ raise UnknownSBCError(f"SBC '{sbc_name}' does not exist")
1594
+ return sbc.id
1595
+
1596
+ def expire_stale_claims(self, grace_seconds: int = 60) -> int:
1597
+ """Mark claims past their deadline (+ grace) as released.
1598
+
1599
+ Returns the number of claims released by this sweep. Safe to call
1600
+ repeatedly; only unreleased rows with an elapsed deadline are touched.
1601
+ """
1602
+ cutoff = self._fmt_ts(datetime.now() - timedelta(seconds=grace_seconds))
1603
+ return self.db.execute_modify(
1604
+ """
1605
+ UPDATE claims
1606
+ SET released_at = CURRENT_TIMESTAMP,
1607
+ release_reason = ?,
1608
+ released_by = 'system'
1609
+ WHERE released_at IS NULL AND expires_at < ?
1610
+ """,
1611
+ (ReleaseReason.EXPIRED.value, cutoff),
1612
+ )
1613
+
1614
+ @staticmethod
1615
+ def _is_pid_alive(pid: int) -> bool:
1616
+ """Check whether a process is alive (works on Linux/macOS)."""
1617
+ try:
1618
+ os.kill(pid, 0)
1619
+ return True
1620
+ except ProcessLookupError:
1621
+ return False
1622
+ except PermissionError:
1623
+ # Process exists but we can't signal it — still alive.
1624
+ return True
1625
+ except OSError:
1626
+ return False
1627
+
1628
+ @staticmethod
1629
+ def _process_start_epoch(pid: int) -> Optional[float]:
1630
+ """Wall-clock start time of ``pid`` from /proc, or None if unknown.
1631
+
1632
+ Linux only: start = boot time (``btime`` in /proc/stat) + the
1633
+ process's ``starttime`` (field 22 of /proc/<pid>/stat, in clock
1634
+ ticks). Returns None on other platforms or any read/parse failure.
1635
+ """
1636
+ try:
1637
+ with open(f"/proc/{pid}/stat") as f:
1638
+ stat = f.read()
1639
+ # comm (field 2) is parenthesised and may contain spaces or ")",
1640
+ # so split after the last ")": the rest starts at field 3.
1641
+ start_ticks = int(stat.rsplit(")", 1)[1].split()[19])
1642
+ with open("/proc/stat") as f:
1643
+ btime = next(
1644
+ int(line.split()[1]) for line in f if line.startswith("btime ")
1645
+ )
1646
+ return btime + start_ticks / os.sysconf("SC_CLK_TCK")
1647
+ except (OSError, ValueError, IndexError, StopIteration):
1648
+ return None
1649
+
1650
+ # Slack for comparing a process start time with a session epoch: both are
1651
+ # derived from the wall clock (btime shifts with clock steps) and rounded.
1652
+ _SESSION_START_SLACK_SECONDS = 2.0
1653
+
1654
+ def _is_session_alive(self, pid: int, epoch: Optional[int]) -> bool:
1655
+ """True if ``pid`` is still the process that created the session.
1656
+
1657
+ An mcp-stdio session ID is ``mcp-stdio:<pid>-<epoch>``, where the
1658
+ server records ``epoch`` shortly *after* it starts. So the original
1659
+ process started at or before ``epoch``, while a process that reuses
1660
+ the PID started after the original exited. When the start time
1661
+ can't be read, fall back to the PID check alone.
1662
+ """
1663
+ if not self._is_pid_alive(pid):
1664
+ return False
1665
+ if epoch is None:
1666
+ return True
1667
+ started = self._process_start_epoch(pid)
1668
+ if started is None:
1669
+ return True
1670
+ return started <= epoch + self._SESSION_START_SLACK_SECONDS
1671
+
1672
+ def release_dead_sessions(self, grace_seconds: int = 60) -> int:
1673
+ """Release claims whose MCP server process has exited.
1674
+
1675
+ Parses ``mcp-stdio:<pid>-<epoch>`` and ``mcp-http:<pid>-<epoch>``
1676
+ session IDs (HTTP ones may end in ``:<username>``). If the session's
1677
+ process is gone (dead PID, or the PID now belongs to a newer
1678
+ process — see ``_is_session_alive``) **and** the claim's deadline +
1679
+ grace has passed, the claim is released as ``session-lost``. Claims
1680
+ with other ``session_kind`` values (cli, web) are skipped.
1681
+ """
1682
+ rows = self.db.execute("""
1683
+ SELECT c.*, s.name AS sbc_name
1684
+ FROM claims c JOIN sbcs s ON s.id = c.sbc_id
1685
+ WHERE c.released_at IS NULL
1686
+ AND c.session_kind IN ('mcp-stdio', 'mcp-http')
1687
+ """)
1688
+ released = 0
1689
+ cutoff = datetime.now() - timedelta(seconds=grace_seconds)
1690
+ for row in rows:
1691
+ claim = Claim.from_row(row)
1692
+ # Parse "mcp-<transport>:<pid>-<epoch>" (epoch optional for old IDs)
1693
+ try:
1694
+ payload = claim.session_id.split(":", 1)[1]
1695
+ pid_part, _, epoch_part = payload.partition("-")
1696
+ pid = int(pid_part)
1697
+ except (IndexError, ValueError):
1698
+ continue
1699
+ try:
1700
+ # Authenticated MCP HTTP appends ":<username>".
1701
+ epoch = int(epoch_part.split(":", 1)[0])
1702
+ except ValueError:
1703
+ epoch = None
1704
+
1705
+ if self._is_session_alive(pid, epoch):
1706
+ continue
1707
+
1708
+ # PID is dead — but only release if past grace
1709
+ if claim.expires_at and claim.expires_at > cutoff:
1710
+ continue
1711
+
1712
+ self.db.execute_modify(
1713
+ """
1714
+ UPDATE claims
1715
+ SET released_at = CURRENT_TIMESTAMP,
1716
+ release_reason = ?,
1717
+ released_by = 'system'
1718
+ WHERE id = ?
1719
+ """,
1720
+ (ReleaseReason.SESSION_LOST.value, claim.id),
1721
+ )
1722
+ logger.info(
1723
+ "Released claim on '%s' (dead session pid=%d, agent=%s)",
1724
+ claim.sbc_name,
1725
+ pid,
1726
+ claim.agent_name,
1727
+ )
1728
+ self._audit_log(
1729
+ "session_lost",
1730
+ "sbc",
1731
+ claim.sbc_id,
1732
+ claim.sbc_name,
1733
+ f"Session pid={pid} died, claim released "
1734
+ f"(was held by {claim.agent_name})",
1735
+ )
1736
+ released += 1
1737
+ return released
1738
+
1739
+ def prune_released_claims(self, older_than_days: int = 30) -> int:
1740
+ """Delete released claim rows older than the retention threshold.
1741
+
1742
+ Only rows with a non-NULL ``released_at`` are eligible. Active
1743
+ (unreleased) claims are never pruned.
1744
+ """
1745
+ cutoff = self._fmt_ts(datetime.now() - timedelta(days=older_than_days))
1746
+ count = self.db.execute_modify(
1747
+ """
1748
+ DELETE FROM claims
1749
+ WHERE released_at IS NOT NULL AND released_at < ?
1750
+ """,
1751
+ (cutoff,),
1752
+ )
1753
+ if count:
1754
+ logger.info(
1755
+ "Pruned %d released claims older than %d days", count, older_than_days
1756
+ )
1757
+ return count
1758
+
1759
+ def prune_activity_events(self, older_than_days: int = 30) -> int:
1760
+ """Delete audit events older than the retention threshold."""
1761
+ count = audit.prune_old_events(self.db, older_than_days=older_than_days)
1762
+ if count:
1763
+ logger.info(
1764
+ "Pruned %d activity events older than %d days",
1765
+ count,
1766
+ older_than_days,
1767
+ )
1768
+ return count
1769
+
1770
+ def get_claim_metrics(self) -> dict:
1771
+ """Aggregate statistics across all claims (active and released)."""
1772
+ rows = self.db.execute("""
1773
+ SELECT
1774
+ COUNT(*) AS total,
1775
+ SUM(CASE WHEN released_at IS NULL THEN 1 ELSE 0 END) AS active,
1776
+ SUM(CASE WHEN release_reason = 'released' THEN 1 ELSE 0 END) AS released,
1777
+ SUM(CASE WHEN release_reason = 'expired' THEN 1 ELSE 0 END) AS expired,
1778
+ SUM(CASE WHEN release_reason = 'force-released' THEN 1 ELSE 0 END) AS force_released,
1779
+ SUM(CASE WHEN release_reason = 'session-lost' THEN 1 ELSE 0 END) AS session_lost,
1780
+ AVG(CASE WHEN released_at IS NOT NULL
1781
+ THEN CAST(
1782
+ (julianday(released_at) - julianday(acquired_at))
1783
+ * 86400 AS INTEGER)
1784
+ ELSE NULL END) AS avg_duration_seconds
1785
+ FROM claims
1786
+ """) # noqa: E501
1787
+ row = rows[0] if rows else None
1788
+ if not row:
1789
+ return {
1790
+ "total": 0,
1791
+ "active": 0,
1792
+ "released": 0,
1793
+ "expired": 0,
1794
+ "force_released": 0,
1795
+ "session_lost": 0,
1796
+ "avg_duration_seconds": None,
1797
+ }
1798
+ return {
1799
+ "total": row["total"] or 0,
1800
+ "active": row["active"] or 0,
1801
+ "released": row["released"] or 0,
1802
+ "expired": row["expired"] or 0,
1803
+ "force_released": row["force_released"] or 0,
1804
+ "session_lost": row["session_lost"] or 0,
1805
+ "avg_duration_seconds": (
1806
+ round(row["avg_duration_seconds"])
1807
+ if row["avg_duration_seconds"] is not None
1808
+ else None
1809
+ ),
1810
+ }
1811
+
1812
+ def claim_sbc(
1813
+ self,
1814
+ sbc_name: str,
1815
+ agent_name: str,
1816
+ session_id: str,
1817
+ session_kind: str,
1818
+ duration_seconds: int,
1819
+ reason: str,
1820
+ context: Optional[dict] = None,
1821
+ grace_seconds: int = 60,
1822
+ owner: Optional[str] = None,
1823
+ ) -> Claim:
1824
+ """Acquire an exclusive claim on an SBC.
1825
+
1826
+ ``owner`` is the authenticated labctl username, if any (see
1827
+ :class:`ClaimActor`).
1828
+
1829
+ Runs an expire-stale sweep inside the acquisition transaction so
1830
+ the partial unique index can't block a legitimate caller behind a
1831
+ claim whose deadline has already passed.
1832
+
1833
+ Refuses (via ChannelBusyError) if any actuator channel referenced
1834
+ by this SBC's bindings is currently held by an in-flight verb.
1835
+ Snapshot check only; ownership across the claim's lifetime is
1836
+ enforced by the DB-backed claim row.
1837
+ """
1838
+ sbc_id = self._require_sbc_id(sbc_name)
1839
+
1840
+ # Phase 6 gate: no actuator-channel verb in flight on bound channels.
1841
+ # Imported lazily to avoid cycles and to keep the manager core
1842
+ # decoupled from the actuators package at import time.
1843
+ from labctl.actuators.runtime import check_no_channel_busy_for_sbc
1844
+
1845
+ check_no_channel_busy_for_sbc(self, sbc_id)
1846
+ context_json = json.dumps(context) if context else None
1847
+ cutoff = self._fmt_ts(datetime.now() - timedelta(seconds=grace_seconds))
1848
+
1849
+ with self.db.connect() as conn:
1850
+ # Release any expired claims on this SBC within the same
1851
+ # transaction so the UNIQUE index reflects current reality.
1852
+ conn.execute(
1853
+ """
1854
+ UPDATE claims
1855
+ SET released_at = CURRENT_TIMESTAMP,
1856
+ release_reason = ?,
1857
+ released_by = 'system'
1858
+ WHERE released_at IS NULL AND expires_at < ?
1859
+ """,
1860
+ (ReleaseReason.EXPIRED.value, cutoff),
1861
+ )
1862
+ # The grace period protects an expired claim from others, not
1863
+ # from its own holder: re-claiming replaces it straight away.
1864
+ replaced = conn.execute(
1865
+ """
1866
+ UPDATE claims
1867
+ SET released_at = CURRENT_TIMESTAMP,
1868
+ release_reason = ?,
1869
+ released_by = 'system'
1870
+ WHERE released_at IS NULL AND sbc_id = ? AND expires_at < ?
1871
+ AND (session_id = ? OR (owner IS NOT NULL AND owner = ?))
1872
+ """,
1873
+ (
1874
+ ReleaseReason.EXPIRED.value,
1875
+ sbc_id,
1876
+ self._fmt_ts(datetime.now()),
1877
+ session_id,
1878
+ owner,
1879
+ ),
1880
+ ).rowcount
1881
+
1882
+ # Check for active claim after the sweep
1883
+ cursor = conn.execute(
1884
+ """
1885
+ SELECT c.*, s.name AS sbc_name
1886
+ FROM claims c
1887
+ JOIN sbcs s ON s.id = c.sbc_id
1888
+ WHERE c.sbc_id = ? AND c.released_at IS NULL
1889
+ """,
1890
+ (sbc_id,),
1891
+ )
1892
+ existing = cursor.fetchone()
1893
+ if existing is not None:
1894
+ raise ClaimConflict(Claim.from_row(existing))
1895
+
1896
+ now = datetime.now()
1897
+ expires_at = now + timedelta(seconds=duration_seconds)
1898
+ cursor = conn.execute(
1899
+ """
1900
+ INSERT INTO claims (
1901
+ sbc_id, agent_name, session_id, session_kind, owner,
1902
+ reason, context_json, acquired_at, duration_seconds,
1903
+ last_activity, expires_at
1904
+ )
1905
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1906
+ """,
1907
+ (
1908
+ sbc_id,
1909
+ agent_name,
1910
+ session_id,
1911
+ session_kind,
1912
+ owner or None,
1913
+ reason,
1914
+ context_json,
1915
+ self._fmt_ts(now),
1916
+ duration_seconds,
1917
+ self._fmt_ts(now),
1918
+ self._fmt_ts(expires_at),
1919
+ ),
1920
+ )
1921
+ claim_id = cursor.lastrowid
1922
+ cursor = conn.execute(
1923
+ """
1924
+ SELECT c.*, s.name AS sbc_name
1925
+ FROM claims c JOIN sbcs s ON s.id = c.sbc_id
1926
+ WHERE c.id = ?
1927
+ """,
1928
+ (claim_id,),
1929
+ )
1930
+ claim = Claim.from_row(cursor.fetchone())
1931
+
1932
+ if replaced:
1933
+ self._audit_log(
1934
+ "claim_expired",
1935
+ "sbc",
1936
+ sbc_id,
1937
+ sbc_name,
1938
+ "Expired claim replaced by its holder's new claim",
1939
+ )
1940
+ self._audit_log(
1941
+ "claim",
1942
+ "sbc",
1943
+ sbc_id,
1944
+ sbc_name,
1945
+ f"Claimed by {agent_name} ({session_kind}) "
1946
+ f"for {duration_seconds}s: {reason}",
1947
+ )
1948
+ return claim
1949
+
1950
+ def get_active_claim(self, sbc_name: str) -> Optional[Claim]:
1951
+ """Return the active claim on an SBC, or None if free."""
1952
+ claim = self.get_open_claim(sbc_name)
1953
+ # If materialized deadline passed, treat as free — the next sweep
1954
+ # will commit the release. Don't return a zombie as active.
1955
+ if claim is None or not claim.is_active:
1956
+ return None
1957
+ self._load_pending_requests(claim)
1958
+ return claim
1959
+
1960
+ def get_open_claim(self, sbc_name: str) -> Optional[Claim]:
1961
+ """The unreleased claim on an SBC, including one past its deadline
1962
+ that hasn't been swept yet, or None."""
1963
+ sbc_id = self._require_sbc_id(sbc_name)
1964
+ row = self.db.execute_one(
1965
+ """
1966
+ SELECT c.*, s.name AS sbc_name
1967
+ FROM claims c JOIN sbcs s ON s.id = c.sbc_id
1968
+ WHERE c.sbc_id = ? AND c.released_at IS NULL
1969
+ """,
1970
+ (sbc_id,),
1971
+ )
1972
+ return Claim.from_row(row) if row else None
1973
+
1974
+ def list_active_claims(self) -> list[Claim]:
1975
+ """All currently active claims across the lab."""
1976
+ rows = self.db.execute("""
1977
+ SELECT c.*, s.name AS sbc_name
1978
+ FROM claims c JOIN sbcs s ON s.id = c.sbc_id
1979
+ WHERE c.released_at IS NULL
1980
+ ORDER BY c.acquired_at ASC
1981
+ """)
1982
+ claims = []
1983
+ for row in rows:
1984
+ claim = Claim.from_row(row)
1985
+ if claim.is_active:
1986
+ self._load_pending_requests(claim)
1987
+ claims.append(claim)
1988
+ return claims
1989
+
1990
+ def list_claim_history(self, sbc_name: str, limit: int = 10) -> list[Claim]:
1991
+ """Past (released) claims for a specific SBC, newest first."""
1992
+ sbc_id = self._require_sbc_id(sbc_name)
1993
+ rows = self.db.execute(
1994
+ """
1995
+ SELECT c.*, s.name AS sbc_name
1996
+ FROM claims c JOIN sbcs s ON s.id = c.sbc_id
1997
+ WHERE c.sbc_id = ? AND c.released_at IS NOT NULL
1998
+ ORDER BY c.released_at DESC, c.id DESC
1999
+ LIMIT ?
2000
+ """,
2001
+ (sbc_id, limit),
2002
+ )
2003
+ return [Claim.from_row(r) for r in rows]
2004
+
2005
+ def release_claim(
2006
+ self,
2007
+ sbc_name: str,
2008
+ session_id: str,
2009
+ owner: Optional[str] = None,
2010
+ released_by: Optional[str] = None,
2011
+ ) -> Claim:
2012
+ """Release the active claim held by ``session_id`` (or by the
2013
+ authenticated user ``owner``, from any session) on an SBC.
2014
+ ``released_by`` names who released it (default: the claim's
2015
+ agent name)."""
2016
+ claim = self.get_active_claim(sbc_name)
2017
+ if claim is None:
2018
+ raise ClaimNotFoundError(f"No active claim on SBC '{sbc_name}'")
2019
+ if not ClaimActor(session_id, owner).holds(claim):
2020
+ raise NotClaimantError(
2021
+ f"Claim on '{sbc_name}' is held by a different session"
2022
+ )
2023
+ self.db.execute_modify(
2024
+ """
2025
+ UPDATE claims
2026
+ SET released_at = CURRENT_TIMESTAMP,
2027
+ release_reason = ?,
2028
+ released_by = ?
2029
+ WHERE id = ?
2030
+ """,
2031
+ (ReleaseReason.RELEASED.value, released_by or claim.agent_name, claim.id),
2032
+ )
2033
+ self._audit_log(
2034
+ "release",
2035
+ "sbc",
2036
+ claim.sbc_id,
2037
+ sbc_name,
2038
+ f"Released by {released_by or claim.agent_name}",
2039
+ )
2040
+ # Re-fetch fresh state so callers see released_at populated.
2041
+ return self._get_claim_by_id(claim.id)
2042
+
2043
+ def force_release_claim(
2044
+ self, sbc_name: str, reason: str, released_by: str = "operator"
2045
+ ) -> Claim:
2046
+ """Operator override — forcibly release the active claim.
2047
+
2048
+ Differs from :meth:`release_claim` in that no session-match check
2049
+ applies. The reason is appended to the audit log prominently.
2050
+ """
2051
+ claim = self.get_active_claim(sbc_name)
2052
+ if claim is None:
2053
+ raise ClaimNotFoundError(f"No active claim on SBC '{sbc_name}'")
2054
+ self.db.execute_modify(
2055
+ """
2056
+ UPDATE claims
2057
+ SET released_at = CURRENT_TIMESTAMP,
2058
+ release_reason = ?,
2059
+ released_by = ?
2060
+ WHERE id = ?
2061
+ """,
2062
+ (ReleaseReason.FORCE_RELEASED.value, released_by, claim.id),
2063
+ )
2064
+ self._audit_log(
2065
+ "force_release",
2066
+ "sbc",
2067
+ claim.sbc_id,
2068
+ sbc_name,
2069
+ f"Force-released by {released_by} "
2070
+ f"(was held by {claim.agent_name}): {reason}",
2071
+ )
2072
+ return self._get_claim_by_id(claim.id)
2073
+
2074
+ def renew_claim(
2075
+ self,
2076
+ sbc_name: str,
2077
+ session_id: str,
2078
+ duration_seconds: Optional[int] = None,
2079
+ owner: Optional[str] = None,
2080
+ ) -> Claim:
2081
+ """Extend an active claim's deadline (held by ``session_id`` or
2082
+ by the authenticated user ``owner``).
2083
+
2084
+ If ``duration_seconds`` is omitted the previous duration is reused.
2085
+ ``last_activity`` and ``expires_at`` are both advanced to "now" and
2086
+ "now + duration_seconds" respectively.
2087
+ """
2088
+ claim = self.get_active_claim(sbc_name)
2089
+ if claim is None:
2090
+ raise ClaimNotFoundError(f"No active claim on SBC '{sbc_name}'")
2091
+ if not ClaimActor(session_id, owner).holds(claim):
2092
+ raise NotClaimantError(
2093
+ f"Claim on '{sbc_name}' is held by a different session"
2094
+ )
2095
+ new_duration = (
2096
+ duration_seconds if duration_seconds is not None else claim.duration_seconds
2097
+ )
2098
+ now = datetime.now()
2099
+ new_expiry = now + timedelta(seconds=new_duration)
2100
+ updated = self.db.execute_modify(
2101
+ """
2102
+ UPDATE claims
2103
+ SET duration_seconds = ?,
2104
+ last_activity = ?,
2105
+ expires_at = ?,
2106
+ renewal_count = renewal_count + 1
2107
+ WHERE id = ? AND released_at IS NULL AND expires_at >= ?
2108
+ """,
2109
+ (
2110
+ new_duration,
2111
+ self._fmt_ts(now),
2112
+ self._fmt_ts(new_expiry),
2113
+ claim.id,
2114
+ self._fmt_ts(now),
2115
+ ),
2116
+ )
2117
+ if not updated: # released or expired meanwhile
2118
+ raise ClaimNotFoundError(f"No active claim on SBC '{sbc_name}'")
2119
+ return self._get_claim_by_id(claim.id)
2120
+
2121
+ def heartbeat_held_claim(
2122
+ self, sbc_name: str, actor: ClaimActor, claim_id: Optional[int] = None
2123
+ ) -> bool:
2124
+ """Heartbeat ``actor``'s active claim on ``sbc_name``; True if it
2125
+ still holds one (and it was heartbeated), False otherwise.
2126
+
2127
+ One conditional UPDATE: the claim must be unreleased, not past its
2128
+ deadline, and held by ``actor`` (:meth:`ClaimActor.holds`), so a
2129
+ claim released or taken over meanwhile is never revived. The new
2130
+ deadline is computed from the row's own duration, so a renew to
2131
+ another duration is respected. With ``claim_id``, only that claim
2132
+ counts (not a later one by the same holder). Unknown SBCs raise
2133
+ UnknownSBCError.
2134
+ """
2135
+ sbc_id = self._require_sbc_id(sbc_name)
2136
+ now = self._fmt_ts(datetime.now())
2137
+ changed = self.db.execute_modify(
2138
+ """
2139
+ UPDATE claims
2140
+ SET last_activity = ?,
2141
+ expires_at = strftime(
2142
+ '%Y-%m-%d %H:%M:%f', ?, '+' || duration_seconds || ' seconds'
2143
+ ) || '000'
2144
+ WHERE sbc_id = ? AND released_at IS NULL AND expires_at >= ?
2145
+ AND (session_id = ? OR (COALESCE(?, '') != '' AND owner = ?))
2146
+ AND (? IS NULL OR id = ?)
2147
+ """,
2148
+ (
2149
+ now,
2150
+ now,
2151
+ sbc_id,
2152
+ now,
2153
+ actor.session_id,
2154
+ actor.owner,
2155
+ actor.owner,
2156
+ claim_id,
2157
+ claim_id,
2158
+ ),
2159
+ )
2160
+ return changed > 0
2161
+
2162
+ def touch_claim(self, claim: Claim) -> bool:
2163
+ """Heartbeat an already-loaded active claim. Returns False when no
2164
+ row changed (released or past its deadline meanwhile)."""
2165
+ now = datetime.now()
2166
+ new_expiry = now + timedelta(seconds=claim.duration_seconds)
2167
+ changed = self.db.execute_modify(
2168
+ """
2169
+ UPDATE claims
2170
+ SET last_activity = ?, expires_at = ?
2171
+ WHERE id = ? AND released_at IS NULL AND expires_at >= ?
2172
+ """,
2173
+ (self._fmt_ts(now), self._fmt_ts(new_expiry), claim.id, self._fmt_ts(now)),
2174
+ )
2175
+ return changed > 0
2176
+
2177
+ def check_claim(
2178
+ self,
2179
+ sbc_name: str,
2180
+ actor: ClaimActor,
2181
+ *,
2182
+ override: bool = False,
2183
+ require_claim: bool = False,
2184
+ include_shared: bool = True,
2185
+ heartbeat: bool = True,
2186
+ ) -> Optional[Claim]:
2187
+ """Gate an operation that changes ``sbc_name`` on its claim (D013).
2188
+
2189
+ Returns the active claim (or None) when ``actor`` may proceed:
2190
+ no claim (unless ``require_claim``), the actor's own claim (which
2191
+ is heartbeated), or an operator's explicit ``override`` (recorded
2192
+ in the activity log). Raises :class:`ClaimConflict` when someone
2193
+ else holds the claim, :class:`NotOperatorError` when an agent asks
2194
+ to override, and :class:`ClaimRequiredError` when ``require_claim``
2195
+ is set and nobody holds it (an operator's ``override`` also covers
2196
+ that case, and is logged). Unknown SBCs raise UnknownSBCError.
2197
+
2198
+ With ``include_shared`` (the default), boards that share hardware
2199
+ with ``sbc_name`` (see :meth:`sbcs_sharing_hardware_with`) are
2200
+ checked too, for someone else's claim only: ``require_claim``
2201
+ applies to the named board alone. A conflict there raises
2202
+ ClaimConflict carrying that board's claim. Only the named board's
2203
+ claim is heartbeated, and not at all with ``heartbeat=False``.
2204
+ """
2205
+ # Heartbeat only once every check has passed (a refused call
2206
+ # mustn't extend the claim), and only the named board's claim
2207
+ # (using A mustn't keep the caller's claim on a sharing B alive).
2208
+ claim = self._check_one_claim(sbc_name, actor, override, require_claim)
2209
+ if include_shared:
2210
+ for other in self.sbcs_sharing_hardware_with(sbc_name):
2211
+ try:
2212
+ self._check_one_claim(other, actor, override, False)
2213
+ except UnknownSBCError:
2214
+ continue # deleted meanwhile: nothing left to protect
2215
+ if heartbeat and claim is not None and actor.holds(claim):
2216
+ self.touch_claim(claim)
2217
+ return claim
2218
+
2219
+ def check_claims_of(
2220
+ self, sbc_names: Iterable[str], actor: ClaimActor, *, override: bool = False
2221
+ ) -> None:
2222
+ """:meth:`check_claim` for boards a call reaches only through
2223
+ shared hardware (wiring a claimed board's plug to another board,
2224
+ driving a relay bound to it): someone else's claim refuses, but
2225
+ ``require_claim`` doesn't apply, nothing is heartbeated and their
2226
+ own sharers aren't followed. Boards deleted meanwhile are skipped."""
2227
+ for name in sorted({n for n in sbc_names if n}):
2228
+ try:
2229
+ self.check_claim(
2230
+ name,
2231
+ actor,
2232
+ override=override,
2233
+ include_shared=False,
2234
+ heartbeat=False,
2235
+ )
2236
+ except UnknownSBCError:
2237
+ continue
2238
+
2239
+ def sbcs_bound_to_channels(
2240
+ self, channel_ids: Iterable[int], *, same_device: bool = True
2241
+ ) -> list[str]:
2242
+ """SBCs bound to one of these actuator channels: a relay wired to a
2243
+ board's reset or power is part of that board (D013).
2244
+ ``same_device`` also covers other actuator records for the same
2245
+ relay board (needed when driving a channel, not when deleting a
2246
+ record)."""
2247
+ channel_ids = set(channel_ids)
2248
+ if not channel_ids:
2249
+ return []
2250
+ if same_device:
2251
+ channel_ids = self.channels_on_same_device(channel_ids)
2252
+ names = {b.sbc_name for b in self.list_bindings(channel_ids=channel_ids)}
2253
+ names.discard(None)
2254
+ return sorted(names)
2255
+
2256
+ def new_claim_conflict(
2257
+ self, sbc_name: str, actor: ClaimActor, grace_seconds: int
2258
+ ) -> Optional[Claim]:
2259
+ """The claim that stops ``actor`` claiming ``sbc_name``, or None.
2260
+
2261
+ First the board's own open claim if someone else holds it, while
2262
+ active or inside its grace period (only its holder may re-claim it
2263
+ then; past the grace :meth:`claim_sbc` sweeps it). Then another
2264
+ user's active claim on a board sharing its hardware: the new claim
2265
+ would let ``actor`` drive that board. The actor's own claim is not
2266
+ returned (callers renew it). Unknown SBCs raise UnknownSBCError.
2267
+ """
2268
+ own = self.get_open_claim(sbc_name)
2269
+ if own is not None and not actor.holds(own):
2270
+ cutoff = datetime.now() - timedelta(seconds=grace_seconds)
2271
+ # Same test as claim_sbc's sweep (expires_at < now - grace).
2272
+ if own.expires_at is not None and own.expires_at >= cutoff:
2273
+ return own
2274
+ for other in self.sbcs_sharing_hardware_with(sbc_name):
2275
+ try:
2276
+ claim = self.get_active_claim(other)
2277
+ except UnknownSBCError:
2278
+ continue
2279
+ if claim is not None and not actor.holds(claim):
2280
+ return claim
2281
+ return None
2282
+
2283
+ def _check_one_claim(
2284
+ self,
2285
+ sbc_name: str,
2286
+ actor: ClaimActor,
2287
+ override: bool,
2288
+ require_claim: bool,
2289
+ ) -> Optional[Claim]:
2290
+ """One board's part of check_claim; never heartbeats (check_claim
2291
+ does, once everything has passed)."""
2292
+ claim = self.get_active_claim(sbc_name)
2293
+ if claim is None:
2294
+ if not require_claim:
2295
+ return None
2296
+ if not override:
2297
+ raise ClaimRequiredError(sbc_name)
2298
+ if not actor.operator:
2299
+ raise NotOperatorError(
2300
+ f"Only operators may act on '{sbc_name}' without a claim"
2301
+ )
2302
+ self._audit_log(
2303
+ "claim_override",
2304
+ "sbc",
2305
+ self._require_sbc_id(sbc_name),
2306
+ sbc_name,
2307
+ "Acted on an unclaimed board without a claim (operator override)",
2308
+ )
2309
+ return None
2310
+ if actor.holds(claim):
2311
+ return claim
2312
+ if not override:
2313
+ raise ClaimConflict(claim)
2314
+ if not actor.operator:
2315
+ raise NotOperatorError(
2316
+ f"Only operators may override the claim on '{sbc_name}' "
2317
+ f"(held by '{claim.agent_name}')"
2318
+ )
2319
+ self._audit_log(
2320
+ "claim_override",
2321
+ "sbc",
2322
+ claim.sbc_id,
2323
+ sbc_name,
2324
+ f"Acted on a board claimed by {claim.agent_name} (operator override)",
2325
+ )
2326
+ return claim
2327
+
2328
+ def record_release_request(
2329
+ self, sbc_name: str, requested_by: str, reason: str
2330
+ ) -> ClaimRequest:
2331
+ """Record a polite release request against the active claim."""
2332
+ claim = self.get_active_claim(sbc_name)
2333
+ if claim is None:
2334
+ raise ClaimNotFoundError(f"No active claim on SBC '{sbc_name}'")
2335
+ request_id = self.db.execute_insert(
2336
+ """
2337
+ INSERT INTO claim_requests (claim_id, requested_by, reason)
2338
+ VALUES (?, ?, ?)
2339
+ """,
2340
+ (claim.id, requested_by, reason),
2341
+ )
2342
+ row = self.db.execute_one(
2343
+ "SELECT * FROM claim_requests WHERE id = ?", (request_id,)
2344
+ )
2345
+ return ClaimRequest.from_row(row)
2346
+
2347
+ def _get_claim_by_id(self, claim_id: int) -> Optional[Claim]:
2348
+ row = self.db.execute_one(
2349
+ """
2350
+ SELECT c.*, s.name AS sbc_name
2351
+ FROM claims c JOIN sbcs s ON s.id = c.sbc_id
2352
+ WHERE c.id = ?
2353
+ """,
2354
+ (claim_id,),
2355
+ )
2356
+ if not row:
2357
+ return None
2358
+ claim = Claim.from_row(row)
2359
+ if claim.released_at is None:
2360
+ self._load_pending_requests(claim)
2361
+ return claim
2362
+
2363
+ # --- Actuator Operations ---
2364
+
2365
+ def create_actuator(
2366
+ self,
2367
+ name: str,
2368
+ driver: DriverName,
2369
+ *,
2370
+ kind: ActuatorKind = ActuatorKind.RELAY,
2371
+ device_path: Optional[str] = None,
2372
+ vid: Optional[str] = None,
2373
+ pid: Optional[str] = None,
2374
+ serial_no: Optional[str] = None,
2375
+ ) -> Actuator:
2376
+ """Register a new actuator."""
2377
+ actuator_id = self.db.execute_insert(
2378
+ """
2379
+ INSERT INTO actuators
2380
+ (name, kind, driver, device_path, vid, pid, serial_no)
2381
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2382
+ """,
2383
+ (
2384
+ name,
2385
+ kind.value,
2386
+ driver.value,
2387
+ device_path,
2388
+ vid,
2389
+ pid,
2390
+ serial_no,
2391
+ ),
2392
+ )
2393
+ self._audit_log(
2394
+ "create",
2395
+ "actuator",
2396
+ actuator_id,
2397
+ name,
2398
+ f"Registered actuator {name!r} (driver={driver.value})",
2399
+ )
2400
+ result = self.get_actuator(actuator_id)
2401
+ if result is None:
2402
+ raise RuntimeError(f"Failed to retrieve actuator {actuator_id}")
2403
+ return result
2404
+
2405
+ def get_actuator(self, actuator_id: int) -> Optional[Actuator]:
2406
+ row = self.db.execute_one(
2407
+ "SELECT * FROM actuators WHERE id = ?", (actuator_id,)
2408
+ )
2409
+ if not row:
2410
+ return None
2411
+ actuator = Actuator.from_row(row)
2412
+ actuator.channels = self.list_actuator_channels(actuator.id)
2413
+ return actuator
2414
+
2415
+ def get_actuator_by_name(self, name: str) -> Optional[Actuator]:
2416
+ row = self.db.execute_one("SELECT * FROM actuators WHERE name = ?", (name,))
2417
+ if not row:
2418
+ return None
2419
+ actuator = Actuator.from_row(row)
2420
+ actuator.channels = self.list_actuator_channels(actuator.id)
2421
+ return actuator
2422
+
2423
+ def list_actuators(self) -> list[Actuator]:
2424
+ rows = self.db.execute("SELECT * FROM actuators ORDER BY name")
2425
+ actuators = [Actuator.from_row(r) for r in rows]
2426
+ # Batch-load channels.
2427
+ if actuators:
2428
+ ids = [a.id for a in actuators]
2429
+ placeholders = ",".join("?" for _ in ids)
2430
+ ch_rows = self.db.execute(
2431
+ f"SELECT * FROM actuator_channels WHERE actuator_id IN ({placeholders})"
2432
+ " ORDER BY actuator_id, channel_index",
2433
+ tuple(ids),
2434
+ )
2435
+ by_aid: dict[int, list[ActuatorChannel]] = {}
2436
+ for r in ch_rows:
2437
+ by_aid.setdefault(r["actuator_id"], []).append(
2438
+ ActuatorChannel.from_row(r)
2439
+ )
2440
+ for a in actuators:
2441
+ a.channels = by_aid.get(a.id, [])
2442
+ return actuators
2443
+
2444
+ def delete_actuator(self, actuator_id: int) -> bool:
2445
+ actuator = self.get_actuator(actuator_id)
2446
+ if not actuator:
2447
+ return False
2448
+ # Capture channel IDs before the cascade-delete so we can drop
2449
+ # their per-channel locks afterwards. Without this the
2450
+ # process-local lock dict accumulates stale entries across
2451
+ # reprovisioning cycles.
2452
+ channel_ids = [ch.id for ch in actuator.channels]
2453
+ count = self.db.execute_modify(
2454
+ "DELETE FROM actuators WHERE id = ?", (actuator_id,)
2455
+ )
2456
+ if count > 0:
2457
+ self._audit_log(
2458
+ "delete",
2459
+ "actuator",
2460
+ actuator_id,
2461
+ actuator.name,
2462
+ f"Deleted actuator {actuator.name!r}",
2463
+ )
2464
+ # Lazy-import to avoid a manager → actuators → manager cycle
2465
+ # at module load time.
2466
+ try:
2467
+ from labctl.actuators.runtime import drop_channel_lock
2468
+
2469
+ for cid in channel_ids:
2470
+ drop_channel_lock(cid)
2471
+ except Exception: # noqa: BLE001
2472
+ logger.debug(
2473
+ "Failed to drop per-channel locks for actuator %d", actuator_id
2474
+ )
2475
+ return True
2476
+ return False
2477
+
2478
+ def record_actuator_probe(self, actuator_id: int, result: str) -> None:
2479
+ """Stamp `last_probe_at` and `last_probe_result` on an actuator."""
2480
+ self.db.execute_modify(
2481
+ """
2482
+ UPDATE actuators
2483
+ SET last_probe_at = CURRENT_TIMESTAMP,
2484
+ last_probe_result = ?
2485
+ WHERE id = ?
2486
+ """,
2487
+ (result, actuator_id),
2488
+ )
2489
+
2490
+ # --- Actuator Channel Operations ---
2491
+
2492
+ def add_actuator_channel(
2493
+ self,
2494
+ actuator_id: int,
2495
+ channel_index: int,
2496
+ *,
2497
+ label: Optional[str] = None,
2498
+ default_state: ChannelState = ChannelState.OPEN,
2499
+ ) -> ActuatorChannel:
2500
+ channel_id = self.db.execute_insert(
2501
+ """
2502
+ INSERT INTO actuator_channels
2503
+ (actuator_id, channel_index, label, default_state)
2504
+ VALUES (?, ?, ?, ?)
2505
+ """,
2506
+ (actuator_id, channel_index, label, default_state.value),
2507
+ )
2508
+ actuator = self.get_actuator(actuator_id)
2509
+ actuator_name = actuator.name if actuator else None
2510
+ self._audit_log(
2511
+ "create",
2512
+ "actuator_channel",
2513
+ channel_id,
2514
+ f"{actuator_name}[{channel_index}]" if actuator_name else None,
2515
+ f"Added channel {channel_index} to {actuator_name!r}",
2516
+ )
2517
+ row = self.db.execute_one(
2518
+ "SELECT * FROM actuator_channels WHERE id = ?", (channel_id,)
2519
+ )
2520
+ if not row:
2521
+ raise RuntimeError(f"Failed to retrieve channel {channel_id}")
2522
+ return ActuatorChannel.from_row(row)
2523
+
2524
+ def list_actuator_channels(self, actuator_id: int) -> list[ActuatorChannel]:
2525
+ rows = self.db.execute(
2526
+ "SELECT * FROM actuator_channels WHERE actuator_id = ?"
2527
+ " ORDER BY channel_index",
2528
+ (actuator_id,),
2529
+ )
2530
+ return [ActuatorChannel.from_row(r) for r in rows]
2531
+
2532
+ def get_actuator_channel(
2533
+ self, actuator_id: int, channel_index: int
2534
+ ) -> Optional[ActuatorChannel]:
2535
+ row = self.db.execute_one(
2536
+ "SELECT * FROM actuator_channels"
2537
+ " WHERE actuator_id = ? AND channel_index = ?",
2538
+ (actuator_id, channel_index),
2539
+ )
2540
+ return ActuatorChannel.from_row(row) if row else None
2541
+
2542
+ def update_channel_state(
2543
+ self,
2544
+ channel_id: int,
2545
+ last_state: ChannelState,
2546
+ *,
2547
+ bump_cycle_count: bool = True,
2548
+ ) -> None:
2549
+ """Stamp last_state, last_changed_at, and increment cycle_count.
2550
+
2551
+ Called by the actuation layer after a successful write. The
2552
+ cycle bump is **suppressed when the new state matches the
2553
+ currently-cached state** — mechanical relays only wear on
2554
+ actual transitions, so a redundant write (e.g. enter_recovery
2555
+ re-applying an already-engaged strap via apply_pre_power) must
2556
+ not inflate the wear metric.
2557
+ """
2558
+ if bump_cycle_count:
2559
+ # Only bump when last_state actually changes. SQLite's
2560
+ # CASE expression keeps this atomic in one statement so
2561
+ # there's no read-then-write race.
2562
+ # - last_state IS NULL → first ever drive of this channel,
2563
+ # count it as one transition.
2564
+ # - last_state != new → real transition, bump.
2565
+ # - else → idempotent re-drive (e.g. pre_power
2566
+ # re-asserting an already-asserted strap), don't bump.
2567
+ self.db.execute_modify(
2568
+ """
2569
+ UPDATE actuator_channels
2570
+ SET last_state = ?,
2571
+ last_changed_at = CURRENT_TIMESTAMP,
2572
+ cycle_count = cycle_count + CASE
2573
+ WHEN last_state IS NULL THEN 1
2574
+ WHEN last_state != ? THEN 1
2575
+ ELSE 0
2576
+ END
2577
+ WHERE id = ?
2578
+ """,
2579
+ (last_state.value, last_state.value, channel_id),
2580
+ )
2581
+ else:
2582
+ self.db.execute_modify(
2583
+ """
2584
+ UPDATE actuator_channels
2585
+ SET last_state = ?, last_changed_at = CURRENT_TIMESTAMP
2586
+ WHERE id = ?
2587
+ """,
2588
+ (last_state.value, channel_id),
2589
+ )
2590
+
2591
+ def commit_actuation(
2592
+ self,
2593
+ channel_id: int,
2594
+ last_state: ChannelState,
2595
+ binding_id: int,
2596
+ desired_state: DesiredState,
2597
+ ) -> None:
2598
+ """Atomically stamp channel last_state + binding desired_state.
2599
+
2600
+ The two writes that complete a verb (channel state and binding
2601
+ intent) must land together: process kill between them would
2602
+ leave ``last_state`` updated but ``desired_state`` stale, and
2603
+ the daemon-restart safe-drive would then drive the channel
2604
+ backwards — exactly the failure mode safe-drive was meant to
2605
+ prevent. Held inside one DB transaction so a crash either
2606
+ commits both or rolls back both.
2607
+ """
2608
+ with self.db.connect() as conn:
2609
+ conn.execute(
2610
+ """
2611
+ UPDATE actuator_channels
2612
+ SET last_state = ?,
2613
+ last_changed_at = CURRENT_TIMESTAMP,
2614
+ cycle_count = cycle_count + CASE
2615
+ WHEN last_state IS NULL THEN 1
2616
+ WHEN last_state != ? THEN 1
2617
+ ELSE 0
2618
+ END
2619
+ WHERE id = ?
2620
+ """,
2621
+ (last_state.value, last_state.value, channel_id),
2622
+ )
2623
+ conn.execute(
2624
+ "UPDATE bindings SET desired_state = ? WHERE id = ?",
2625
+ (desired_state.value, binding_id),
2626
+ )
2627
+
2628
+ # --- Binding Operations ---
2629
+
2630
+ def create_binding(
2631
+ self,
2632
+ sbc_id: int,
2633
+ purpose: str,
2634
+ actuator_channel_id: int,
2635
+ *,
2636
+ shape_mode: ShapeMode,
2637
+ shape_active: ChannelState,
2638
+ momentary_pulse_ms: Optional[int] = None,
2639
+ sample_phase: SamplePhase = SamplePhase.NONE,
2640
+ notes: Optional[str] = None,
2641
+ ) -> Binding:
2642
+ if sample_phase is SamplePhase.POST_POWER:
2643
+ raise ValueError(POST_POWER_REFUSED)
2644
+ binding_id = self.db.execute_insert(
2645
+ """
2646
+ INSERT INTO bindings
2647
+ (sbc_id, purpose, actuator_channel_id,
2648
+ shape_mode, shape_active,
2649
+ momentary_pulse_ms, sample_phase, notes)
2650
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2651
+ """,
2652
+ (
2653
+ sbc_id,
2654
+ purpose,
2655
+ actuator_channel_id,
2656
+ shape_mode.value,
2657
+ shape_active.value,
2658
+ momentary_pulse_ms,
2659
+ sample_phase.value,
2660
+ notes,
2661
+ ),
2662
+ )
2663
+ sbc = self.get_sbc(sbc_id)
2664
+ sbc_name = sbc.name if sbc else None
2665
+ self._audit_log(
2666
+ "create",
2667
+ "binding",
2668
+ binding_id,
2669
+ f"{sbc_name}:{purpose}" if sbc_name else purpose,
2670
+ f"Bound {purpose!r} on {sbc_name!r} → channel id {actuator_channel_id}",
2671
+ )
2672
+ result = self.get_binding(binding_id)
2673
+ if result is None:
2674
+ raise RuntimeError(f"Failed to retrieve binding {binding_id}")
2675
+ return result
2676
+
2677
+ def get_binding(self, binding_id: int) -> Optional[Binding]:
2678
+ row = self.db.execute_one(
2679
+ """
2680
+ SELECT b.*, s.name AS sbc_name,
2681
+ a.name AS actuator_name, c.channel_index AS channel_index
2682
+ FROM bindings b
2683
+ LEFT JOIN sbcs s ON s.id = b.sbc_id
2684
+ LEFT JOIN actuator_channels c ON c.id = b.actuator_channel_id
2685
+ LEFT JOIN actuators a ON a.id = c.actuator_id
2686
+ WHERE b.id = ?
2687
+ """,
2688
+ (binding_id,),
2689
+ )
2690
+ return Binding.from_row(row) if row else None
2691
+
2692
+ def get_binding_by_target(self, sbc_id: int, purpose: str) -> Optional[Binding]:
2693
+ row = self.db.execute_one(
2694
+ """
2695
+ SELECT b.*, s.name AS sbc_name,
2696
+ a.name AS actuator_name, c.channel_index AS channel_index
2697
+ FROM bindings b
2698
+ LEFT JOIN sbcs s ON s.id = b.sbc_id
2699
+ LEFT JOIN actuator_channels c ON c.id = b.actuator_channel_id
2700
+ LEFT JOIN actuators a ON a.id = c.actuator_id
2701
+ WHERE b.sbc_id = ? AND b.purpose = ?
2702
+ """,
2703
+ (sbc_id, purpose),
2704
+ )
2705
+ return Binding.from_row(row) if row else None
2706
+
2707
+ def list_bindings(
2708
+ self,
2709
+ sbc_id: Optional[int] = None,
2710
+ channel_ids: Optional[Iterable[int]] = None,
2711
+ ) -> list[Binding]:
2712
+ sql = (
2713
+ "SELECT b.*, s.name AS sbc_name,"
2714
+ " a.name AS actuator_name, c.channel_index AS channel_index"
2715
+ " FROM bindings b"
2716
+ " LEFT JOIN sbcs s ON s.id = b.sbc_id"
2717
+ " LEFT JOIN actuator_channels c ON c.id = b.actuator_channel_id"
2718
+ " LEFT JOIN actuators a ON a.id = c.actuator_id"
2719
+ )
2720
+ where: list[str] = []
2721
+ params: list = []
2722
+ if sbc_id is not None:
2723
+ where.append("b.sbc_id = ?")
2724
+ params.append(sbc_id)
2725
+ if channel_ids is not None:
2726
+ ids = list(channel_ids)
2727
+ if not ids:
2728
+ return []
2729
+ where.append(f"b.actuator_channel_id IN ({','.join('?' * len(ids))})")
2730
+ params.extend(ids)
2731
+ if where:
2732
+ sql += " WHERE " + " AND ".join(where)
2733
+ sql += " ORDER BY s.name, b.purpose"
2734
+ return [Binding.from_row(r) for r in self.db.execute(sql, tuple(params))]
2735
+
2736
+ def delete_binding(self, binding_id: int) -> bool:
2737
+ binding = self.get_binding(binding_id)
2738
+ if not binding:
2739
+ return False
2740
+ count = self.db.execute_modify(
2741
+ "DELETE FROM bindings WHERE id = ?", (binding_id,)
2742
+ )
2743
+ if count > 0:
2744
+ label = (
2745
+ f"{binding.sbc_name}:{binding.purpose}"
2746
+ if binding.sbc_name
2747
+ else binding.purpose
2748
+ )
2749
+ self._audit_log(
2750
+ "delete",
2751
+ "binding",
2752
+ binding_id,
2753
+ label,
2754
+ f"Removed binding {label}",
2755
+ )
2756
+ return True
2757
+ return False
2758
+
2759
+ def update_binding_desired_state(
2760
+ self, binding_id: int, desired: DesiredState
2761
+ ) -> None:
2762
+ self.db.execute_modify(
2763
+ "UPDATE bindings SET desired_state = ? WHERE id = ?",
2764
+ (desired.value, binding_id),
2765
+ )
2766
+
2767
+ # --- Audit Log ---
2768
+
2769
+ def _audit_log(
2770
+ self,
2771
+ action: str,
2772
+ entity_type: str,
2773
+ entity_id: Optional[int],
2774
+ entity_name: Optional[str],
2775
+ details: str,
2776
+ result: str = "ok",
2777
+ ) -> None:
2778
+ """Log an action to the activity stream.
2779
+
2780
+ Thin wrapper over `labctl.core.audit.emit()` that preserves the
2781
+ legacy positional-string-details calling convention used across
2782
+ the manager. `actor`, `source`, and `claim_id` are pulled from
2783
+ the audit contextvars.
2784
+ """
2785
+ audit.emit(
2786
+ self.db,
2787
+ action=action,
2788
+ entity_type=entity_type,
2789
+ entity_id=entity_id,
2790
+ entity_name=entity_name,
2791
+ result=result,
2792
+ details={"message": details} if details else None,
2793
+ )
2794
+
2795
+
2796
+ def get_manager(
2797
+ db_path: Path,
2798
+ *,
2799
+ timeout_seconds: float = DEFAULT_DB_TIMEOUT,
2800
+ base_tcp_port: int = 4000,
2801
+ ) -> ResourceManager:
2802
+ """
2803
+ Get a resource manager instance.
2804
+
2805
+ Args:
2806
+ db_path: Path to database file.
2807
+ timeout_seconds: SQLite busy-wait passed to the underlying
2808
+ ``Database`` (default matches Database default).
2809
+ base_tcp_port: Lowest auto-assigned TCP port (``serial.base_tcp_port``).
2810
+
2811
+ Returns:
2812
+ Initialized ResourceManager instance.
2813
+ """
2814
+ db = get_database(db_path, timeout_seconds=timeout_seconds)
2815
+ return ResourceManager(db, base_tcp_port=base_tcp_port)