embedded-lab-control 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/core/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)
|