Add D455 sensor host and shared Node/Core preparation surface
This commit is contained in:
@@ -13,6 +13,7 @@ from cryptography import x509
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from missioncore_plugin_sdk.v0alpha2.identity import ExecutionBinding
|
||||
|
||||
from . import sensors
|
||||
from .trust import SCHEMA, CoreTrust, PairingError, node_request, parse_invitation, pem, public_id
|
||||
|
||||
|
||||
@@ -205,7 +206,10 @@ class FleetRegistry:
|
||||
"last_seen": row["last_seen"],
|
||||
"host": row["inventory"],
|
||||
"execution_binding": row["runtime"],
|
||||
"devices": [],
|
||||
"devices": row.get("sensor_state", {}).get("items", []),
|
||||
"sensor_state": row.get(
|
||||
"sensor_state", {"items": [], "operations": [], "preparation": None}
|
||||
),
|
||||
"notice": row["notice"],
|
||||
"core_endpoint": row["binding"]["endpoint"],
|
||||
}
|
||||
@@ -366,12 +370,10 @@ class FleetRegistry:
|
||||
if path != "/v1/node/heartbeat":
|
||||
return 404, {"error": "Unknown operation"}
|
||||
binding = ExecutionBinding.model_validate(value["execution_binding"])
|
||||
if (
|
||||
binding.node_id != node_id
|
||||
or binding.platform.value != "linux"
|
||||
or value.get("devices") != []
|
||||
):
|
||||
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)
|
||||
host = value.get("host")
|
||||
if (
|
||||
not isinstance(host, dict)
|
||||
@@ -385,6 +387,7 @@ class FleetRegistry:
|
||||
receipt=None,
|
||||
last_seen=time.time(),
|
||||
inventory=host,
|
||||
sensor_state=sensor_state,
|
||||
runtime=binding.model_dump(mode="json"),
|
||||
notice="",
|
||||
)
|
||||
@@ -396,4 +399,4 @@ class FleetRegistry:
|
||||
}
|
||||
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
|
||||
self.save(row)
|
||||
return 200, {"ok": True, "client_pem": row["binding"]["client_pem"]}
|
||||
return 200, {"ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Fleet projects Node-owned sensor state and forwards SDK operations, never hardware calls."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest
|
||||
from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
|
||||
|
||||
from .trust import PairingError
|
||||
|
||||
ACTIONS = {
|
||||
"prepare",
|
||||
"details",
|
||||
"rename",
|
||||
"verify",
|
||||
"start",
|
||||
"stop",
|
||||
"option",
|
||||
"offer",
|
||||
"close-peer",
|
||||
}
|
||||
|
||||
|
||||
def validate_inventory(value, node_id):
|
||||
items = value.get("devices", [])
|
||||
if not isinstance(items, list) or len(items) > 16:
|
||||
raise ValueError("Invalid device inventory")
|
||||
seen = set()
|
||||
for item in items:
|
||||
snapshot = DeviceSessionSnapshot.model_validate(item["snapshot"])
|
||||
if (
|
||||
snapshot.context.execution.node_id != node_id
|
||||
or snapshot.context.device.device_id != item["id"]
|
||||
or item["id"] in seen
|
||||
):
|
||||
raise ValueError("Invalid device execution binding")
|
||||
seen.add(item["id"])
|
||||
state = value.get("sensor_state", {"items": items, "operations": [], "preparation": None})
|
||||
if state.get("items") != items or len(json.dumps(state)) > 262144:
|
||||
raise ValueError("Invalid sensor state")
|
||||
return state
|
||||
|
||||
|
||||
def submit(fleet, vehicle_id, value):
|
||||
command = OperationRequest.model_validate(value)
|
||||
if (
|
||||
command.action_id not in ACTIONS
|
||||
or command.idempotency_key != command.operation_id
|
||||
or len(json.dumps(value)) > 65536
|
||||
):
|
||||
raise PairingError("Неподдерживаемая команда устройства.")
|
||||
with fleet.lock:
|
||||
row = fleet.find(vehicle_id)
|
||||
if fleet.public(row)["connectivity"] != "online":
|
||||
raise PairingError("БК недоступен. Дождитесь связи перед настройкой устройства.")
|
||||
existing = row.setdefault("sensor_commands", {}).get(command.operation_id)
|
||||
if existing:
|
||||
if existing["command"] != value:
|
||||
raise PairingError("Идентификатор операции уже использован.")
|
||||
return existing
|
||||
if (
|
||||
not datetime.now(UTC) < command.deadline_at
|
||||
or (command.deadline_at - command.requested_at).total_seconds() > 360
|
||||
):
|
||||
raise PairingError("Срок команды истёк. Обновите устройство.")
|
||||
item = next(
|
||||
(
|
||||
item
|
||||
for item in row.get("sensor_state", {}).get("items", [])
|
||||
if item["id"] == command.session.device_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if item is None or item["snapshot"]["context"]["session_id"] != command.session.session_id:
|
||||
raise PairingError("Сеанс устройства изменился. Обновите сведения.")
|
||||
row["sensor_commands"] = {
|
||||
k: v for k, v in row["sensor_commands"].items() if time.time() - v["updated_at"] < 600
|
||||
}
|
||||
if len(row["sensor_commands"]) >= 32:
|
||||
raise PairingError("Слишком много запросов к устройству. Повторите позже.")
|
||||
operation = {"command": value, "state": "queued", "updated_at": time.time()}
|
||||
row["sensor_commands"][command.operation_id] = operation
|
||||
fleet.save(row)
|
||||
return operation
|
||||
|
||||
|
||||
def operation(fleet, vehicle_id, identifier):
|
||||
with fleet.lock:
|
||||
value = fleet.find(vehicle_id).get("sensor_commands", {}).get(identifier)
|
||||
if value is None:
|
||||
raise PairingError("Операция не найдена.")
|
||||
if value["state"] in ("queued", "running") and datetime.fromisoformat(
|
||||
value["command"]["deadline_at"]
|
||||
) < datetime.now(UTC):
|
||||
return {
|
||||
**value,
|
||||
"state": "unknown",
|
||||
"error": "Подтверждение не получено. Обновите фактическое состояние устройства.",
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def heartbeat(row, value):
|
||||
commands = row.setdefault("sensor_commands", {})
|
||||
acknowledgements = []
|
||||
results = value.get("sensor_results", [])
|
||||
if not isinstance(results, list) or len(results) > 64:
|
||||
raise ValueError("Invalid sensor operations")
|
||||
for result in results:
|
||||
command = result.get("command", {})
|
||||
identifier = command.get("operation_id")
|
||||
existing = commands.get(identifier)
|
||||
if (
|
||||
existing
|
||||
and existing["command"] == command
|
||||
and result.get("state") in ("running", "complete", "error", "unknown")
|
||||
):
|
||||
commands[identifier] = {**result, "updated_at": time.time()}
|
||||
if result["state"] != "running":
|
||||
acknowledgements.append(identifier)
|
||||
pending = []
|
||||
for entry in commands.values():
|
||||
if entry["state"] == "queued" and datetime.fromisoformat(
|
||||
entry["command"]["deadline_at"]
|
||||
) > datetime.now(UTC):
|
||||
pending.append(entry["command"])
|
||||
return {"sensor_commands": pending[:4], "sensor_acknowledgements": acknowledgements}
|
||||
@@ -57,7 +57,7 @@ class NodeChannelHandler(BaseHTTPRequestHandler):
|
||||
if (
|
||||
self.headers.get("Content-Type") != "application/json"
|
||||
or self.headers.get("Origin")
|
||||
or not 0 < size <= 65536
|
||||
or not 0 < size <= 1048576
|
||||
):
|
||||
raise ValueError
|
||||
value = json.loads(self.rfile.read(size))
|
||||
|
||||
@@ -83,3 +83,27 @@ def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_
|
||||
return fleet.revoke(vehicle_id)
|
||||
except PairingError as error:
|
||||
raise HTTPException(404, str(error)) from None
|
||||
|
||||
|
||||
@router.post("/{vehicle_id}/devices/operations")
|
||||
def sensor_command(
|
||||
vehicle_id: str, body: dict, fleet: Annotated[FleetRegistry, Depends(local_operator)]
|
||||
):
|
||||
from k1link.fleet.sensors import submit
|
||||
|
||||
try:
|
||||
return submit(fleet, vehicle_id, body)
|
||||
except (PairingError, ValueError) as error:
|
||||
raise HTTPException(409, str(error)) 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)]
|
||||
):
|
||||
from k1link.fleet.sensors import operation
|
||||
|
||||
try:
|
||||
return operation(fleet, vehicle_id, operation_id)
|
||||
except PairingError as error:
|
||||
raise HTTPException(404, str(error)) from None
|
||||
|
||||
Reference in New Issue
Block a user