feat(fleet): preserve operator VESC integration before final driver merge
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""VESC configuration storage on Core; serial execution remains on the board."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user