feat(fleet): replace onboard computer without replacing apparatus

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 21:38:13 +03:00
parent 8283f9ba85
commit d2ec9e95b1
5 changed files with 245 additions and 11 deletions
+32 -9
View File
@@ -52,6 +52,10 @@ class FleetRegistry:
"CREATE TABLE IF NOT EXISTS vehicles (id TEXT PRIMARY KEY, "
"node_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)"
)
self.db.execute(
"CREATE TABLE IF NOT EXISTS board_history (vehicle_id TEXT NOT NULL, "
"revision INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(vehicle_id, revision))"
)
self.db.commit()
from k1link.device_plugins.vesc.archive import Archive
@@ -74,11 +78,14 @@ class FleetRegistry:
json.loads(row[0]) for row in self.db.execute("SELECT body FROM vehicles ORDER BY id")
]
def save(self, row):
def save(self, row, *, previous=None):
with self.db:
if previous is not None:
self.db.execute("INSERT INTO board_history VALUES(?,?,?)",
(previous["id"], previous["revision"], json.dumps(previous)))
self.db.execute(
"INSERT INTO vehicles VALUES(?,?,?) "
"ON CONFLICT(id) DO UPDATE SET body=excluded.body",
"ON CONFLICT(id) DO UPDATE SET node_id=excluded.node_id, body=excluded.body",
(row["id"], row["node_id"], json.dumps(row)),
)
self.events.notify()
@@ -164,17 +171,31 @@ class FleetRegistry:
"endpoint": invitation["endpoint"],
}
def add(self, preview_id: str, name: str, platform: str) -> dict:
def add(self, preview_id: str, name: str, platform: str, *,
vehicle_id: str | None = None, expected_revision: int | None = None) -> dict:
with self.lock:
preview = self.previews.get(preview_id)
if preview is None or preview["expires"] <= time.time():
raise PairingError("Проверка приглашения истекла. Вставьте код снова.")
if preview.get("created_id"):
return self.public(self.find(preview["created_id"]))
current = self.find(preview["created_id"])
if (preview.get("target_id") != vehicle_id
or current["binding"]["binding_id"] != preview.get("created_binding")):
raise PairingError("Приглашение уже использовано. Проверьте БК заново.")
return self.public(current)
target = self.find(vehicle_id) if vehicle_id is not None else None
if target is not None:
if expected_revision != target["revision"]:
raise PairingError("Привязка БК изменилась. Обновите аппарат и повторите проверку.")
from .replacement import check_replacement
check_replacement(self, target)
name, platform = target["name"], target["platform"]
invitation = preview["invitation"]
existing = next(
(row for row in self.rows() if row["node_id"] == invitation["node_id"]), None
)
if target is not None and existing is not None and existing["id"] != target["id"]:
raise PairingError("Этот БК относится к другому аппарату. Выберите другое приглашение.")
if existing and existing["enrollment"] in ("pending", "paired"):
if existing.get("invitation_id") == invitation["id"]:
return self.public(existing)
@@ -201,13 +222,14 @@ class FleetRegistry:
"ca_pem": pem(self.trust.ca),
"client_pem": self.trust.leaf(invitation["node_id"], preview["public_key"]),
}
previous = target or existing
row = {
"id": existing["id"] if existing else secrets.token_urlsafe(16),
"id": previous["id"] if previous else secrets.token_urlsafe(16),
"node_id": invitation["node_id"],
"name": name.strip(),
"platform": platform,
"enrollment": "pending",
"revision": (existing["revision"] + 1) if existing else 1,
"revision": (previous["revision"] + 1) if previous else 1,
"binding": binding,
"invitation_id": invitation["id"],
"invitation": invitation,
@@ -218,10 +240,11 @@ class FleetRegistry:
"runtime": None,
"certificate_previous": None,
"notice": "Подтверждаем привязку с БК",
"created_at": time.time(),
"created_at": previous["created_at"] if previous else time.time(),
}
self.save(row)
self.previews[preview_id] = {"created_id": row["id"], "expires": preview["expires"]}
self.save(row, previous=previous)
self.previews[preview_id] = {"created_id": row["id"], "created_binding": binding["binding_id"],
"target_id": vehicle_id, "expires": preview["expires"]}
return self.public(row)
def public(self, row):
+29
View File
@@ -0,0 +1,29 @@
"""Admission for replacing the computer of an existing vehicle, never its identity."""
import time
from datetime import datetime
from .trust import PairingError
def check_replacement(fleet, row):
if row["enrollment"] == "pending":
raise PairingError("Дождитесь завершения текущей привязки БК или отзовите её.")
control = fleet.rover_control.view(row["node_id"])
if control["controlling"] or (control["fresh"] and control["snapshot"].get("state") in
("preparing", "ready", "driving", "stopping")):
raise PairingError("Сначала завершите управление аппаратом и дождитесь остановки.")
fleet.device_enrollment.prune()
if any(entry["binding"] == row["binding"]["binding_id"]
and entry["public"]["state"] in ("queued", "running")
for (vehicle_id, _), entry in fleet.device_enrollment.pending.items()
if vehicle_id == row["id"]):
raise PairingError("Дождитесь завершения подключения беспроводного устройства.")
for receipt in row.get("sensor_commands", {}).values():
if receipt.get("state") not in ("queued", "running"):
continue
try:
deadline = datetime.fromisoformat(receipt["command"]["deadline_at"].replace("Z", "+00:00")).timestamp()
except (KeyError, ValueError, TypeError):
deadline = float("inf")
if deadline > time.time():
raise PairingError("Дождитесь завершения операции с устройствами БК.")
+17
View File
@@ -48,6 +48,12 @@ class AddRequest(BaseModel):
platform: str = Field(pattern="^(ugv|uav|stationary|other)$")
class AttachBoardRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
preview_id: str = Field(min_length=43, max_length=43)
expected_revision: int = Field(ge=1, strict=True)
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
@@ -165,6 +171,17 @@ def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_
raise HTTPException(404, str(error)) from None
@router.post("/{vehicle_id}/computer")
def fleet_attach_board(vehicle_id: str, body: AttachBoardRequest, response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
response.headers["Cache-Control"] = "no-store"
try:
return fleet.add(body.preview_id, "", "", vehicle_id=vehicle_id,
expected_revision=body.expected_revision)
except PairingError as error:
raise HTTPException(409, str(error)) from None
@router.post("/{vehicle_id}/devices/operations")
def sensor_command(
vehicle_id: str, body: dict, fleet: Annotated[FleetRegistry, Depends(local_operator)]
+5 -2
View File
@@ -27,7 +27,9 @@ def rover_state(vehicle_id: str, response: Response,
def rover_arm(vehicle_id: str, body: dict,
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
try:
return fleet.rover_control.arm(node(fleet, vehicle_id), body)
# Admission and node resolution must not race a computer replacement.
with fleet.lock:
return fleet.rover_control.arm(node(fleet, vehicle_id), body)
except (ValueError, PairingError) as error:
raise HTTPException(409, str(error)) from None
@@ -35,6 +37,7 @@ def rover_arm(vehicle_id: str, body: dict,
def rover_command(vehicle_id: str, body: dict,
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
try:
return fleet.rover_control.command(node(fleet, vehicle_id), body)
with fleet.lock:
return fleet.rover_control.command(node(fleet, vehicle_id), body)
except (ValueError, PairingError) as error:
raise HTTPException(409, str(error)) from None