feat(fleet): replace onboard computer without replacing apparatus
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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("Дождитесь завершения операции с устройствами БК.")
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user