Files
NODEDC_MISSION_CORE/apps/node-agent/sensors/server.py
T

194 lines
8.3 KiB
Python

"""Private Unix plugin host; the Go broker is the only UI/Core authority."""
import asyncio
import hashlib
import json
import os
import re
import uuid
from datetime import UTC, datetime
from pathlib import Path
import pyrealsense2 as rs
from aiohttp import web
from device import Device, atomic, device_id
from media import Peers
from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest
ROOT = Path("/var/lib/mission-core-sensors")
SOCKET = Path("/run/mission-core-sensors/driver.sock")
class Host:
def __init__(self, root=ROOT):
self.root = root
self.context = rs.context()
self.devices = {}
self.execution = None
self.peers = Peers()
self.scan_lock = asyncio.Lock()
self.operation_locks = {}
async def scan(self):
if self.execution is None:
return
async with self.scan_lock:
def work():
found = set()
for dev in self.context.query_devices():
if dev.is_playback():
continue
if dev.get_info(rs.camera_info.product_id).lower() != "0b5c":
continue
serial = dev.get_info(rs.camera_info.serial_number)
# SDK module serial and USB serial are distinct on D455.
# Resolve the actual transport ancestor, not list order or model count.
physical = Path(dev.get_info(rs.camera_info.physical_port)).resolve()
usb_serial = None
for parent in (physical, *physical.parents):
if (
(parent / "idVendor").exists()
and (parent / "idProduct").exists()
and (parent / "idVendor").read_text().strip() == "8086"
and (parent / "idProduct").read_text().strip() == "0b5c"
):
usb_serial = (parent / "serial").read_text().strip()
break
if not usb_serial:
continue
ident = device_id(usb_serial)
found.add(ident)
if ident not in self.devices:
self.devices[ident] = Device(serial, self.root, self.execution, usb_serial)
self.devices[ident].refresh(dev)
for ident, device in self.devices.items():
if ident not in found:
device.disconnect()
await asyncio.to_thread(work)
async def inventory(self, request):
node_id = request.headers.get("X-Node-Id", "")
if not re.fullmatch(r"node_[0-9a-f]{64}", node_id):
raise web.HTTPForbidden()
if self.execution is None:
self.execution = {
"node_id": node_id,
"agent_instance_id": "driver_" + uuid.uuid4().hex,
"platform": "linux",
}
if self.execution["node_id"] != node_id:
raise web.HTTPForbidden()
await self.scan()
return web.json_response(
{"items": [device.snapshot(False) for device in self.devices.values()]}
)
async def operation(self, request):
value = await request.json()
command = OperationRequest.model_validate(value)
identifier = command.operation_id
if not re.fullmatch(r"op_[0-9a-f]{32}", identifier):
raise ValueError("Некорректный идентификатор операции.")
path = self.root / (identifier + ".json")
digest = hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
lock = self.operation_locks.setdefault(identifier, asyncio.Lock())
async with lock:
if path.exists():
previous = json.loads(path.read_text())
if previous["digest"] != digest:
raise ValueError("Идентификатор операции уже использован.")
return web.json_response(previous["result"])
if command.deadline_at <= datetime.now(UTC):
raise ValueError("Срок команды истёк. Состояние камеры не изменено.")
device = self.devices.get(command.session.device_id)
if device is None or command.session.session_id != device.session:
raise ValueError("Сеанс камеры изменился. Обновите сведения.")
# Persist uncertainty before any side effect. A crash must not replay START.
receipt = {
"digest": digest,
"result": {
"state": "unknown",
"error": "Результат операции пока неизвестен. Обновите состояние устройства.",
},
}
atomic(path, receipt)
try:
action, params = command.action_id, dict(command.parameters)
if action == "details":
result = device.snapshot()
elif action == "rename":
result = device.rename(params.get("name"))
elif action == "verify":
result = await asyncio.to_thread(device.verify)
elif action == "start":
if type(params.get("record", False)) is not bool:
raise ValueError("Некорректный режим записи.")
await asyncio.to_thread(
device.start, params.get("profiles"), params.get("record", False)
)
result = {"ok": True}
elif action == "replay":
result = await asyncio.to_thread(device.replay, params.get("recording_id"))
elif action == "stop":
result = await asyncio.to_thread(device.stop)
elif action == "option":
result = await asyncio.to_thread(
device.set_option, params.get("id"), params.get("value")
)
elif action == "offer":
result = await self.peers.offer(device, params)
elif action == "close-peer":
result = await self.peers.close(params.get("peer_id"))
else:
raise ValueError("Неподдерживаемая операция устройства.")
receipt["result"] = {"state": "complete", "result": result}
except (ValueError, RuntimeError) as error:
receipt["result"] = {"state": "error", "error": str(error)[:400]}
atomic(path, receipt)
self.operation_locks.pop(identifier, None)
return web.json_response(receipt["result"])
async def prepare_safe(self, request):
return web.json_response(
{
"safe": all(
d.pipeline is None
and d.acquisition not in ("preparing", "starting", "stopping")
for d in self.devices.values()
)
}
)
async def cleanup(self, app):
for peer in list(self.peers.items):
await self.peers.close(peer)
for device in self.devices.values():
await asyncio.to_thread(device.stop, True)
@web.middleware
async def errors(request, handler):
try:
return await handler(request)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Некорректная команда устройства."}, status=400)
except RuntimeError:
return web.json_response(
{"error": "Драйвер не смог выполнить запрос. Проверьте подключение камеры."}, status=409
)
def main():
os.umask(0o007)
host = Host()
app = web.Application(client_max_size=65536, middlewares=[errors])
app.router.add_get("/inventory", host.inventory)
app.router.add_get("/prepare-safe", host.prepare_safe)
app.router.add_post("/operation", host.operation)
app.on_cleanup.append(host.cleanup)
if SOCKET.exists():
SOCKET.unlink()
web.run_app(app, path=str(SOCKET), print=None, access_log=None, shutdown_timeout=20)