130 lines
5.1 KiB
Python
130 lines
5.1 KiB
Python
"""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",
|
|
"replay",
|
|
"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}
|