feat(fleet): preserve operator VESC integration before final driver merge

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:40:46 +03:00
parent dad11b47d7
commit 71a648fec8
128 changed files with 22390 additions and 59 deletions
@@ -0,0 +1 @@
"""VESC configuration storage on Core; serial execution remains on the board."""
+131
View File
@@ -0,0 +1,131 @@
"""Immutable VESC backups, shared by Core and the packaged board plugin.
Storage has no hardware or restore authority. Versions survive transient
operation receipt cleanup, reboots and controller disconnects.
"""
import base64
import hashlib
import json
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from contextlib import contextmanager
def validate(value):
if not isinstance(value, dict) or len(json.dumps(value)) > 40000:
raise ValueError("Invalid configuration backup")
if value.get("schema") != "missioncore.vesc.config-backup/v1":
raise ValueError("Unsupported backup format")
if not re.fullmatch(r"op_[0-9a-f]{32}", value.get("operation_id", "")):
raise ValueError("Invalid backup identity")
identity = value["identity"]
uid = identity["uuid"]
if not re.fullmatch(r"[0-9a-f]{24}", uid) or int(uid, 16) == 0:
raise ValueError("Invalid controller identity")
expected = "vesc_" + hashlib.sha256(("uuid:" + uid).encode()).hexdigest()[:32]
if value.get("device_id") != expected:
raise ValueError("Backup controller mismatch")
if datetime.fromisoformat(value["observed_at"].replace("Z", "+00:00")).tzinfo is None:
raise ValueError("Backup time is missing its timezone")
if set(value["configs"]) != {"motor", "application"}:
raise ValueError("Incomplete backup")
for key, command in (("motor", 14), ("application", 17)):
blob = value["configs"][key]
data = base64.b64decode(blob["payload"], validate=True)
if (blob.get("encoding") != "base64" or not 5 <= len(data) <= 10000
or data[0] != command or blob["bytes"] != len(data)
or blob["sha256"] != hashlib.sha256(data).hexdigest()
or blob["signature_hex"] != data[1:5].hex()):
raise ValueError("Backup integrity check failed")
return value
def metadata(sequence, value):
return {"sequence": sequence, "id": value["operation_id"], "device_id": value["device_id"],
"observed_at": value["observed_at"], "firmware": value["identity"]["version"],
"configs": {key: {"sha256": blob["sha256"], "bytes": blob["bytes"]}
for key, blob in value["configs"].items()}}
class Archive:
def __init__(self, root):
root = Path(root)
root.mkdir(mode=0o700, parents=True, exist_ok=True)
if root.is_symlink() or root.stat().st_mode & 0o077:
raise ValueError("Configuration archive must be private")
self.path = root / "configurations.sqlite3"
if self.path.is_symlink():
raise ValueError("Invalid configuration archive path")
with self.connect() as db:
db.execute("CREATE TABLE IF NOT EXISTS versions (seq INTEGER PRIMARY KEY AUTOINCREMENT, "
"node TEXT NOT NULL, device TEXT NOT NULL, operation TEXT NOT NULL, "
"body TEXT NOT NULL, UNIQUE(node,device,operation))")
self.path.chmod(0o600)
@contextmanager
def connect(self):
db = sqlite3.connect(self.path, timeout=10)
try:
db.execute("PRAGMA synchronous=FULL")
with db:
yield db
finally:
db.close()
def add(self, node, value):
validate(value)
body = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
with self.connect() as db:
existing = db.execute("SELECT body FROM versions WHERE node=? AND device=? AND operation=?",
(node, value["device_id"], value["operation_id"])).fetchone()
if existing:
if existing[0] != body:
raise ValueError("An immutable configuration version changed")
return
db.execute("INSERT INTO versions(node,device,operation,body) VALUES(?,?,?,?)",
(node, value["device_id"], value["operation_id"], body))
def listing(self, node, device, before=0):
if type(before) is not int or before < 0:
raise ValueError("Invalid archive cursor")
with self.connect() as db:
rows = db.execute("SELECT seq,body FROM versions WHERE node=? AND device=? "
"AND (?=0 OR seq<?) ORDER BY seq DESC LIMIT 51",
(node, device, before, before)).fetchall()
return {"items": [metadata(seq, json.loads(body)) for seq, body in rows[:50]],
"next": str(rows[49][0]) if len(rows) > 50 else None}
def read(self, node, device, operation):
with self.connect() as db:
row = db.execute("SELECT body FROM versions WHERE node=? AND device=? AND operation=?",
(node, device, operation)).fetchone()
if row is None:
raise ValueError("Configuration version not found")
return json.loads(row[0])
def export(self, node, after):
if type(after) is not int or after < 0:
raise ValueError("Invalid archive cursor")
with self.connect() as db:
rows = db.execute("SELECT seq,body FROM versions WHERE node=? AND seq>? ORDER BY seq LIMIT 4",
(node, after)).fetchall()
return {"after": after, "items": [{"sequence": seq, "backup": json.loads(body)} for seq, body in rows],
"next": rows[-1][0] if rows else after}
def receive(self, node, batch):
if not isinstance(batch, dict) or len(json.dumps(batch)) > 165000:
raise ValueError("Invalid archive batch")
items, last = batch.get("items"), batch.get("after")
if not isinstance(items, list) or len(items) > 4 or type(last) is not int or last < 0:
raise ValueError("Invalid archive batch")
for row in items:
seq = row["sequence"]
if type(seq) is not int or seq <= last:
raise ValueError("Invalid archive sequence")
self.add(node, row["backup"])
last = seq
if batch.get("next") != last:
raise ValueError("Invalid archive acknowledgement")
return last
+45
View File
@@ -0,0 +1,45 @@
"""Operator presentation only; never transported to a motor controller."""
import hashlib
import json
from pathlib import Path
from .trust import atomic_private
SCHEMA = "missioncore.board-layout/v1"
SECTIONS = ("computer", "settings", "devices")
def path(root: Path, vehicle: str) -> Path:
return root / "board-layouts" / (hashlib.sha256(vehicle.encode()).hexdigest() + ".json")
def read(root: Path, vehicle: str) -> dict:
target = path(root, vehicle)
if not target.exists():
return {"schema": SCHEMA, "revision": 0, "open_sections": list(SECTIONS)}
if target.is_symlink() or target.stat().st_size > 4096:
raise ValueError("Invalid layout file")
value = json.loads(target.read_bytes())
if (not isinstance(value, dict) or set(value) != {"schema", "revision", "open_sections"}
or value["schema"] != SCHEMA or type(value["revision"]) is not int
or not 0 <= value["revision"] < 2**53-1
or not isinstance(value["open_sections"], list)
or any(item not in SECTIONS for item in value["open_sections"])
or len(set(value["open_sections"])) != len(value["open_sections"])):
raise ValueError("Invalid layout document")
return value
def update(root: Path, vehicle: str, section: str, opened: bool) -> dict:
# Caller holds the fleet writer lock. A patch cannot lose another section.
if section not in SECTIONS or type(opened) is not bool:
raise ValueError("Invalid layout change")
value = read(root, vehicle)
opened_sections = set(value["open_sections"])
if opened:
opened_sections.add(section)
else:
opened_sections.discard(section)
value.update(revision=value["revision"]+1, open_sections=[s for s in SECTIONS if s in opened_sections])
atomic_private(path(root, vehicle), (json.dumps(value, indent=2)+"\n").encode())
return value
+46 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import logging
import secrets
import sqlite3
import threading
@@ -36,6 +37,8 @@ class FleetRegistry:
from .monitor import MonitorReceiver
self.monitor = MonitorReceiver(root)
from .rover_control import RoverControl
self.rover_control = RoverControl()
self.trust = CoreTrust(root)
path = root / "fleet.sqlite3"
if path.is_symlink():
@@ -50,6 +53,16 @@ class FleetRegistry:
"node_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)"
)
self.db.commit()
from k1link.device_plugins.vesc.archive import Archive
self.vesc_archive = Archive(root / "vesc-configurations")
# Preserve pre-archive releases' completed backups before the ordinary
# short-lived operation receipts are pruned.
for row in self.rows():
for receipt in row.get("sensor_commands", {}).values():
if receipt.get("state") == "complete" and receipt.get("command", {}).get("action_id") == "vesc.config.backup":
with suppress(ValueError, KeyError, TypeError):
self.vesc_archive.add(row["node_id"], receipt["result"])
self.previews: dict[str, dict] = {}
self.listeners: dict[str, object] = {}
self.stop = threading.Event()
@@ -359,7 +372,11 @@ class FleetRegistry:
if not isinstance(key, Ed25519PublicKey):
return 403, {"error": "Unauthorized Node"}
node_id = public_id("node_", key)
entered = time.monotonic()
with self.lock:
waited = time.monotonic() - entered
if waited > .025:
logging.getLogger(__name__).warning("fleet timing path=%s lock_ms=%.1f", path, waited*1000)
row = next((item for item in self.rows() if item["node_id"] == node_id), None)
if (
row is None
@@ -390,7 +407,7 @@ class FleetRegistry:
)
self.save(row)
return 200, {"ok": True}
if path != "/v1/node/heartbeat":
if path not in ("/v1/node/heartbeat", "/v1/node/rover", "/v1/node/rover-stream"):
return 404, {"error": "Unknown operation"}
# Endpoint revisions prevent an old in-flight heartbeat or lost ack
# from reversing a migration. Use the actual listener, not a claimed IP.
@@ -411,11 +428,33 @@ class FleetRegistry:
row["core_address"] = address
row["binding"]["endpoint"] = value["core_endpoint"]
row["binding"]["endpoint_revision"] = revision
if path in ("/v1/node/rover", "/v1/node/rover-stream"):
if row["enrollment"] != "paired":
return 410, {"error": "Pairing incomplete"}
if path.endswith("-stream"):
relay = value.get("relay_id")
if not isinstance(relay, str) or len(relay) != 32:
return 400, {"error": "Invalid relay"}
return 200, self.rover_control.stream(node_id, relay)
return 200, self.rover_control.exchange(node_id, value)
binding = ExecutionBinding.model_validate(value["execution_binding"])
if binding.node_id != node_id or binding.platform.value != "linux":
return 400, {"error": "Invalid Node inventory"}
sensor_state = sensors.validate_inventory(value, node_id)
sensor_response = sensors.heartbeat(row, value)
archive_ack = None
archive_started = time.monotonic()
try:
if "vesc_configurations" in value:
archive_ack = self.vesc_archive.receive(node_id, value["vesc_configurations"])
for receipt in row.get("sensor_commands", {}).values():
if receipt.get("state") == "complete" and receipt.get("command", {}).get("action_id") == "vesc.config.backup":
self.vesc_archive.add(node_id, receipt["result"])
except (ValueError, KeyError, TypeError, sqlite3.Error, OSError):
# No archive acknowledgement until durable storage succeeds.
# Archive failure must never delay a control/stop response.
archive_ack = None
archive_elapsed = time.monotonic() - archive_started
enrollment_response = self.device_enrollment.heartbeat(row, value)
host = value.get("host")
if (
@@ -441,7 +480,12 @@ class FleetRegistry:
"until": time.time() + 3600,
}
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
save_started = time.monotonic()
self.save(row)
save_elapsed = time.monotonic() - save_started
if archive_elapsed > .025 or save_elapsed > .025:
logging.getLogger(__name__).warning(
"fleet heartbeat timing archive_ms=%.1f save_ms=%.1f", archive_elapsed*1000, save_elapsed*1000)
monitor_ack = None
try:
monitor_ack = self.monitor.submit(node_id, value.get("monitor"))
@@ -449,6 +493,7 @@ class FleetRegistry:
# Telemetry persistence failure must not block device control.
pass
return 200, {
"vesc_configurations_ack": archive_ack,
"monitor_ack": monitor_ack,
"ok": True,
"client_pem": row["binding"]["client_pem"],
+135
View File
@@ -0,0 +1,135 @@
"""Ephemeral operator leases. No motor calls, command queue or persisted motion."""
from __future__ import annotations
import copy
import math
import secrets
import threading
import time
class RoverControl:
def __init__(self, clock=time.monotonic):
self.clock = clock
self.lock = threading.RLock()
self.changed = threading.Condition(self.lock)
self.boards = {}
self.clock_id = secrets.token_hex(16)
def _board(self, node):
return self.boards.setdefault(node, {"seen": -1e9, "watch": 0, "snapshot": {},
"session": None, "relay": None})
def view(self, node):
with self.lock:
b = self._board(node)
b["watch"] = self.clock() + 2
fresh = self.clock() - b["seen"] < 1
self._expire(b)
return {"fresh": fresh, "snapshot": copy.deepcopy(b["snapshot"]) if fresh else {},
"controlling": b["session"] is not None}
def _expire(self, b):
if b["session"] and self.clock() >= b["session"]["until"]:
b["session"] = None
def arm(self, node, body):
if (set(body) != {"standstill_confirmed", "current_a", "max_erpm"}
or body["standstill_confirmed"] is not True
or type(body["current_a"]) not in (int, float)
or not .5 <= body["current_a"] <= 30
or type(body["max_erpm"]) not in (int, float)
or not 300 <= body["max_erpm"] <= 3000):
raise ValueError("Подтвердите остановку и выберите допустимые пределы.")
with self.lock:
b = self._board(node)
self._expire(b)
if b["session"]:
raise ValueError("Аппаратом уже управляют. Сначала остановите управление.")
snapshot = b["snapshot"]
if self.clock() - b["seen"] >= 1 or snapshot.get("supported") is not True:
raise ValueError("Канал управления бортом недоступен.")
if snapshot.get("state") in ("preparing", "ready", "driving", "stopping"):
raise ValueError("Дождитесь завершения предыдущего управления.")
identifier = secrets.token_hex(16)
b["session"] = {"id": identifier, "sequence": 0, "until": self.clock()+.4,
"left": 0, "right": 0, "settings": copy.deepcopy(body)}
self.changed.notify_all()
return {"session_id": identifier}
def command(self, node, body):
if set(body) != {"session_id", "sequence", "left", "right", "stop"}:
raise ValueError("Некорректная команда управления.")
if (type(body["sequence"]) is not int or not 1 <= body["sequence"] < 2**53
or type(body["stop"]) is not bool
or any(type(body[k]) not in (int,float) or not math.isfinite(body[k])
or abs(body[k]) > 1 for k in ("left", "right"))):
raise ValueError("Некорректные значения команды.")
with self.lock:
b = self._board(node)
self._expire(b)
s = b["session"]
if not s or not secrets.compare_digest(str(body["session_id"]), s["id"]):
raise ValueError("Управление завершено. Включите его заново.")
if body["sequence"] <= s["sequence"]:
raise ValueError("Устаревшая команда отклонена.")
if body["stop"]:
b["session"] = None
else:
s.update(sequence=body["sequence"], left=body["left"], right=body["right"],
until=self.clock()+.4)
self.changed.notify_all()
return {"accepted_sequence": body["sequence"]}
def exchange(self, node, body):
"""Called only after normal paired-certificate/binding authentication."""
snapshot = body.get("rover")
relay = body.get("relay_id")
if not isinstance(snapshot, dict) or not isinstance(relay, str) or len(relay) != 32:
raise ValueError("Invalid rover exchange")
with self.lock:
b = self._board(node)
if b["relay"] != relay:
b["session"] = None # No resume after relay restart/rebind.
if b["snapshot"].get("instance") != snapshot.get("instance"):
b["session"] = None # Driver restarts also revoke the browser lease.
b.update(relay=relay, snapshot=copy.deepcopy(snapshot), seen=self.clock())
self._expire(b)
s = b["session"]
if s and snapshot.get("session_id") == s["id"] and snapshot.get("state") in (
"stopped", "fault", "receiver"):
b["session"] = s = None
command = None
if s:
command = {k: copy.deepcopy(v) for k,v in s.items() if k != "until"}
command["ttl_ms"] = max(0, min(400, int((s["until"]-self.clock())*1000)))
return {"watch": self.clock() < b["watch"] or s is not None, "command": command,
"control_clock": self._clock()}
def _clock(self):
return {"instance": self.clock_id, "monotonic_ms": self.clock()*1000}
def wait_for_intent(self, node, relay, previous, timeout=.05):
"""Wake on a new intent, including updates between write and wait."""
previous = previous or {}
def changed():
board = self._board(node)
current = (board["session"] if board["relay"] == relay else None) or {}
return (current.get("id"), current.get("sequence")) != (previous.get("id"), previous.get("sequence"))
with self.changed:
self.changed.wait_for(changed, timeout)
def stream(self, node, relay):
"""Latest intent only. Telemetry round trips never pace command delivery."""
with self.lock:
b = self._board(node)
self._expire(b)
if self.clock() - b["seen"] >= 1:
b["session"] = None
s = b["session"] if b["relay"] == relay else None
command = None
if s:
command = {k: copy.deepcopy(v) for k,v in s.items() if k != "until"}
command["expires_mono_ms"] = s["until"]*1000
return {"watch": self.clock() < b["watch"] or s is not None,
"command": command, "control_clock": self._clock()}
+2
View File
@@ -30,6 +30,8 @@ ACTIONS = {
"files.list",
"recovery.configure",
"power.wake",
"vesc.link.check", "vesc.telemetry.read", "vesc.limits.read",
"vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.layout", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release",
}
MAX_INVENTORY_ITEMS = 500
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024
+28
View File
@@ -47,6 +47,9 @@ class NodeChannelServer(ThreadingHTTPServer):
class NodeChannelHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
# Header and JSON writes must not wait for a delayed ACK on the paired
# command channel. StreamRequestHandler applies TCP_NODELAY to the socket.
disable_nagle_algorithm = True
def log_message(self, *_args):
pass
@@ -74,6 +77,31 @@ class NodeChannelHandler(BaseHTTPRequestHandler):
except (ValueError, KeyError, TypeError):
self.close_connection = True
status, data = 400, {"error": "Invalid request"}
if self.path == "/v1/node/rover-stream" and status == 200:
self.close_connection = True
self.send_response(200)
self.send_header("Content-Type", "application/x-ndjson")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "close")
self.end_headers()
try:
while not self.server.registry.stop.is_set():
self.wfile.write(json.dumps(data, allow_nan=False).encode()+b"\n")
if not data.get("watch"):
break
# Every frame rechecks the same certificate and binding.
# No queued intent and no lease renewal from a keepalive.
self.server.registry.rover_control.wait_for_intent(
value["node_id"], value["relay_id"], data.get("command"))
status, data = self.server.registry.receive(
self.connection.getpeercert(binary_form=True), self.path,
value, address=self.server.address,
)
if status != 200:
break
except (OSError, ValueError):
pass
return
encoded = json.dumps(data).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
+2
View File
@@ -112,6 +112,7 @@ from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
from k1link.web.artifact_health_api import build_artifact_health_router
from k1link.web.compute_contour_api import build_compute_contour_router
from k1link.web.fleet_api import router as fleet_router
from k1link.web.rover_api import router as rover_router
from k1link.web.device_plugin_composition import load_installed_device_plugins
from k1link.web.e30_engineering_api import build_e30_engineering_router
from k1link.web.e30_human_review_api import build_e30_human_review_router
@@ -1004,6 +1005,7 @@ app.add_middleware(ResponseCompressionMiddleware, minimum_size=1_024, compressle
app.include_router(fleet_router)
app.include_router(rover_router)
@app.exception_handler(RequestValidationError)
+62 -2
View File
@@ -6,12 +6,12 @@ import asyncio
import ipaddress
import json
from contextlib import suppress
from typing import Annotated
from typing import Annotated, Literal
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, StrictBool
from k1link.fleet.registry import FleetRegistry
from k1link.fleet.trust import PairingError
@@ -51,6 +51,42 @@ class AddRequest(BaseModel):
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
class BoardLayoutChange(BaseModel):
model_config = ConfigDict(extra="forbid")
section: Literal["computer", "settings", "devices"]
open: StrictBool
@router.get("/{vehicle_id}/board-layout")
def board_layout_read(vehicle_id: str, response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
from k1link.fleet.board_layout import read
response.headers["Cache-Control"] = "no-store"
try:
with fleet.lock:
fleet.find(vehicle_id)
return read(fleet.root, vehicle_id)
except PairingError as error:
raise HTTPException(404, str(error)) from None
except (OSError, ValueError):
raise HTTPException(500, "Не удалось прочитать раскладку аппарата.") from None
@router.patch("/{vehicle_id}/board-layout")
def board_layout_patch(vehicle_id: str, body: BoardLayoutChange, response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
from k1link.fleet.board_layout import update
response.headers["Cache-Control"] = "no-store"
try:
with fleet.lock:
fleet.find(vehicle_id)
return update(fleet.root, vehicle_id, body.section, body.open)
except PairingError as error:
raise HTTPException(404, str(error)) from None
except (OSError, ValueError):
raise HTTPException(500, "Не удалось сохранить раскладку аппарата.") from None
@router.get("/{vehicle_id}/monitor")
def board_monitor(vehicle_id: str, response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)],
@@ -141,6 +177,30 @@ def sensor_command(
raise HTTPException(409, str(error)) from None
@router.get("/{vehicle_id}/devices/{device_id}/configurations")
def vesc_configurations(vehicle_id: str, device_id: str, response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)], before: int = 0):
response.headers["Cache-Control"] = "no-store"
try:
with fleet.lock:
node = fleet.find(vehicle_id)["node_id"]
return fleet.vesc_archive.listing(node, device_id, before)
except (PairingError, ValueError):
raise HTTPException(404, "История конфигураций недоступна.") from None
@router.get("/{vehicle_id}/devices/{device_id}/configurations/{version_id}")
def vesc_configuration(vehicle_id: str, device_id: str, version_id: str, response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
response.headers["Cache-Control"] = "no-store"
try:
with fleet.lock:
node = fleet.find(vehicle_id)["node_id"]
return fleet.vesc_archive.read(node, device_id, version_id)
except (PairingError, ValueError):
raise HTTPException(404, "Версия конфигурации не найдена.") from None
@router.get("/{vehicle_id}/devices/operations/{operation_id}")
def sensor_operation(
vehicle_id: str, operation_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]
+22 -3
View File
@@ -1,9 +1,11 @@
"""Planning-profile preparation and preview; never an acquisition endpoint."""
import gzip
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, Response
from fastapi import APIRouter, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from starlette.concurrency import run_in_threadpool
@@ -47,8 +49,25 @@ def build_planning_live_router(service):
raise HTTPException(409, str(exc)) from exc
@router.get("/active")
async def active():
return await call(service.get)
async def active(request: Request):
accepts_gzip = "gzip" in request.headers.get("accept-encoding", "")
def encoded():
# A completed report can contain megabytes of numerical evidence.
# Returning its dict sends it through FastAPI's recursive encoder
# and gzip on the event loop, delaying unrelated control requests.
# Keep the exact JSON contract, but finish both operations here.
response = JSONResponse(service.get(), headers={"Cache-Control": "no-store"})
if accepts_gzip and len(response.body) >= 1024:
return Response(
gzip.compress(response.body, compresslevel=5, mtime=0),
media_type="application/json",
headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding",
"Cache-Control": "no-store"},
)
return response
return await call(encoded)
@router.get("")
async def history():
+40
View File
@@ -0,0 +1,40 @@
"""Local operator BFF for the paired Node's bounded control channel."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response
from k1link.fleet.registry import FleetRegistry
from k1link.fleet.trust import PairingError
from .fleet_api import local_operator
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
def node(fleet, vehicle):
with fleet.lock:
row = fleet.find(vehicle)
if row["enrollment"] != "paired":
raise ValueError("Борт не привязан.")
return row["node_id"]
@router.get("/{vehicle_id}/rover")
def rover_state(vehicle_id: str, response: Response,
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
response.headers["Cache-Control"] = "no-store"
try:
return fleet.rover_control.view(node(fleet, vehicle_id))
except (ValueError, PairingError) as error:
raise HTTPException(409, str(error)) from None
@router.post("/{vehicle_id}/rover/arm")
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)
except (ValueError, PairingError) as error:
raise HTTPException(409, str(error)) from None
@router.post("/{vehicle_id}/rover/command")
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)
except (ValueError, PairingError) as error:
raise HTTPException(409, str(error)) from None