fix(node): verify D455 access and share sensor progress and recording UI

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 23:25:11 +03:00
parent b1aaa40508
commit a8647c4d87
25 changed files with 497 additions and 61 deletions
@@ -2,11 +2,11 @@ import {useMemo} from 'react';
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
import {fleetRequest} from '../../core/fleet/useFleet';
export function VehicleSensors({vehicleID,enabled}:{vehicleID:string;enabled:boolean}){
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
const transport=useMemo<SensorTransport>(()=>({
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return value.sensor_state;},
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
}),[vehicleID]);
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled}/>;
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
}
@@ -18,6 +18,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [sensorOpen, setSensorOpen] = useState(false);
const [revoking, setRevoking] = useState<Vehicle | null>(null);
const lastCreateRequest = useRef(createRequest);
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
@@ -43,16 +44,16 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
{fleet.error && <p role="alert">{fleet.error}</p>}
{!adding && error && <p role="alert">{error}</p>}
{detail ? <>
<div><Button onClick={() => setSelected(null)}>К списку аппаратов</Button></div>
<SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
<div><Button onClick={() => {setSelected(null);setSensorOpen(false);}}>К списку аппаратов</Button></div>
{!sensorOpen && <SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
{detail.notice && <p role="status">{detail.notice}</p>}
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
</dl>
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
</SettingsCard>
<SettingsCard title="Устройства аппарата"><VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
</SettingsCard>}
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
@@ -3,3 +3,7 @@
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
.fleet-facts { font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
.fleet-facts dt { color: var(--nodedc-text-secondary); }
.fleet-facts dd { color: var(--nodedc-text-muted); }
+23 -4
View File
@@ -196,7 +196,7 @@ func (s *Sensors) Inventory() map[string]any {
name = n
}
s.mu.Unlock()
items = append(items, map[string]any{"id": id, "name": name, "model": "RealSense D455", "prepared": false, "verified": false, "online": true, "usb": read("speed") + " Мбит/с", "layers": []any{}, "snapshot": map[string]any{"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.0", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now}, "revision": 0, "enrollment": "empty", "connectivity": "connected", "acquisition": "idle", "observed_at": now}})
items = append(items, map[string]any{"id": id, "name": name, "model": "RealSense D455", "prepared": false, "verified": false, "online": true, "usb": read("speed") + " Мбит/с", "layers": []any{}, "snapshot": map[string]any{"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.6", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now}, "revision": 0, "enrollment": "empty", "connectivity": "connected", "acquisition": "idle", "observed_at": now}})
}
var preparation any
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
@@ -206,17 +206,21 @@ func (s *Sensors) Inventory() map[string]any {
operations := []any{}
for _, v := range s.operations {
if time.Now().Unix()-v.Updated < 600 {
operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "state": v.State, "error": v.Error})
operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "requested_at": v.Command.Requested, "state": v.State, "error": v.Error})
}
}
s.mu.Unlock()
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
}
func sensorViewAction(action string) bool {
return action == "details" || action == "offer" || action == "close-peer"
}
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
return nil, errors.New("Некорректная команда устройства.")
}
if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] {
if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "replay": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] {
return nil, errors.New("Операция не поддерживается.")
}
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
@@ -239,7 +243,10 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
}
for _, v := range s.operations {
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID {
if v.State == "running" && v.Command.Action == "prepare" {
return nil, errors.New("Подготовка модели ещё выполняется.")
}
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
return nil, errors.New("Другая операция устройства ещё выполняется.")
}
}
@@ -266,6 +273,7 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
func (s *Sensors) execute(c SensorCommand) {
var result any
var err error
uncertain := false
inv := s.Inventory()
var item map[string]any
for _, v := range inv["items"].([]any) {
@@ -293,6 +301,7 @@ func (s *Sensors) execute(c SensorCommand) {
} else {
var v map[string]any
v, err = s.driver("/operation", c)
uncertain = err != nil || v["state"] == "unknown"
if err == nil {
if v["state"] == "complete" {
result = v["result"]
@@ -308,6 +317,9 @@ func (s *Sensors) execute(c SensorCommand) {
v.Updated = time.Now().Unix()
if err != nil {
v.State = "error"
if uncertain {
v.State = "unknown"
}
v.Error = err.Error()
} else {
v.State = "complete"
@@ -321,6 +333,13 @@ func (s *Sensors) execute(c SensorCommand) {
func (s *Sensors) prepare(c SensorCommand) (any, error) {
s.prepareMu.Lock()
defer s.prepareMu.Unlock()
for _, raw := range s.Inventory()["items"].([]any) {
item := raw.(map[string]any)
snap := item["snapshot"].(map[string]any)
if state := snap["acquisition"]; state != "idle" && state != "failed" {
return nil, errors.New("Остановите захват камер перед подготовкой модели.")
}
}
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
+81 -5
View File
@@ -1,10 +1,86 @@
package node
import (
"testing"
"time"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
)
func sensorTestCommand() SensorCommand {now:=time.Now();id:="op_01234567890123456789012345678901";return SensorCommand{APIVersion:SensorSchema,Kind:"OperationRequest",ID:id,Idempotency:id,Session:SensorSession{SessionID:"session_test",DeviceID:"rsd455_01234567890123456789012345678901"},Action:"start",Requested:now.UTC().Format(time.RFC3339Nano),Deadline:now.Add(time.Minute).UTC().Format(time.RFC3339Nano),Parameters:map[string]any{}}}
func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T){s,e:=OpenSensors(t.TempDir(),"node_test");if e!=nil{t.Fatal(e)};c:=sensorTestCommand();c.Action="shell";if _,e=s.Submit(c,false);e==nil{t.Fatal("arbitrary action admitted")};c=sensorTestCommand();c.ID="../../owned";if _,e=s.Submit(c,false);e==nil{t.Fatal("path admitted")};c=sensorTestCommand();c.Requested=time.Now().Add(-2*time.Minute).Format(time.RFC3339Nano);c.Deadline=time.Now().Add(-time.Minute).Format(time.RFC3339Nano);if _,e=s.Submit(c,false);e==nil{t.Fatal("expired command admitted")}}
func TestSensorUncertainCrashDoesNotReplay(t *testing.T){root:=t.TempDir();s,_:=OpenSensors(root,"node_test");c:=sensorTestCommand();old:=&SensorOperation{Command:c,State:"running",Updated:time.Now().Unix()};if e:=s.write(c.ID+".json",old);e!=nil{t.Fatal(e)};s,e:=OpenSensors(root,"node_test");if e!=nil{t.Fatal(e)};v,e:=s.Submit(c,false);if e!=nil||v.State!="unknown"{t.Fatalf("replay: %+v %v",v,e)};c.Parameters=map[string]any{"record":true};if _,e=s.Submit(c,false);e==nil{t.Fatal("id collision did not reject different command")}}
func sensorTestCommand() SensorCommand {
now := time.Now()
id := "op_01234567890123456789012345678901"
return SensorCommand{APIVersion: SensorSchema, Kind: "OperationRequest", ID: id, Idempotency: id, Session: SensorSession{SessionID: "session_test", DeviceID: "rsd455_01234567890123456789012345678901"}, Action: "start", Requested: now.UTC().Format(time.RFC3339Nano), Deadline: now.Add(time.Minute).UTC().Format(time.RFC3339Nano), Parameters: map[string]any{}}
}
func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T) {
s, e := OpenSensors(t.TempDir(), "node_test")
if e != nil {
t.Fatal(e)
}
c := sensorTestCommand()
c.Action = "shell"
if _, e = s.Submit(c, false); e == nil {
t.Fatal("arbitrary action admitted")
}
c = sensorTestCommand()
c.ID = "../../owned"
if _, e = s.Submit(c, false); e == nil {
t.Fatal("path admitted")
}
c = sensorTestCommand()
c.Requested = time.Now().Add(-2 * time.Minute).Format(time.RFC3339Nano)
c.Deadline = time.Now().Add(-time.Minute).Format(time.RFC3339Nano)
if _, e = s.Submit(c, false); e == nil {
t.Fatal("expired command admitted")
}
}
func TestSensorUncertainCrashDoesNotReplay(t *testing.T) {
root := t.TempDir()
s, _ := OpenSensors(root, "node_test")
c := sensorTestCommand()
old := &SensorOperation{Command: c, State: "running", Updated: time.Now().Unix()}
if e := s.write(c.ID+".json", old); e != nil {
t.Fatal(e)
}
s, e := OpenSensors(root, "node_test")
if e != nil {
t.Fatal(e)
}
v, e := s.Submit(c, false)
if e != nil || v.State != "unknown" {
t.Fatalf("replay: %+v %v", v, e)
}
c.Parameters = map[string]any{"record": true}
if _, e = s.Submit(c, false); e == nil {
t.Fatal("id collision did not reject different command")
}
}
type sensorRoundTrip func(*http.Request) (*http.Response, error)
func (f sensorRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
for _, response := range []string{`{"state":"unknown","error":"uncertain"}`, "transport-failure"} {
t.Run(response, func(t *testing.T) {
s, _ := OpenSensors(t.TempDir(), "node_test")
c := sensorTestCommand()
s.operations[c.ID] = &SensorOperation{Command: c, State: "running"}
s.client = &http.Client{Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
body := `{"items":[{"id":"` + c.Session.DeviceID + `"}]}`
if r.URL.Path == "/operation" {
if response == "transport-failure" {
return nil, errors.New("connection lost")
}
body = response
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
})}
s.execute(c)
if s.Get(c.ID).State != "unknown" {
t.Fatal("uncertain hardware effect reported as definite failure")
}
})
}
}
@@ -2,3 +2,4 @@
SUBSYSTEM=="usb", ATTR{idVendor}=="8086", ATTR{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="video4linux", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="iio", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors", RUN+="/usr/bin/python3 -I /usr/lib/mission-core-node/realsense_iio_access.py %p"
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "8a79dfe84d895c9f1d42b8d285bc6670114f939f"
DG_COMMIT = "17e150b1c74ab8a345fe34ce51dccd5bb862fa85"
def guideline_sources():
+2 -2
View File
@@ -13,7 +13,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.6.0"
VERSION = "0.6.6"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
@@ -92,7 +92,7 @@ Description: Mission Core onboard computer configuration
]:
files.append((path, (p / source).read_bytes(), mode))
files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644))
for name in ("realsense_prepare.py",):
for name in ("realsense_prepare.py", "realsense_iio_access.py"):
files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644))
for name in ("mission-core-realsense.service", "mission-core-node-realsense-prepare.service"):
files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644))
@@ -12,6 +12,7 @@ StateDirectoryMode=0700
RuntimeDirectory=mission-core-sensors
RuntimeDirectoryMode=0750
UMask=0077
Environment=OPENBLAS_NUM_THREADS=1
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
+1
View File
@@ -14,6 +14,7 @@ case "$1" in
systemctl daemon-reload
systemctl enable mission-core-node.service
systemctl restart mission-core-node.service
systemctl try-restart mission-core-realsense.service
fi
;;
esac
+6
View File
@@ -2,6 +2,12 @@
set -eu
if [ "$1" = install ] || [ "$1" = upgrade ]; then
if [ -d /run/systemd/system ]; then
if [ -S /run/mission-core-sensors/driver.sock ]; then
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("driver",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-sensors/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
echo "Mission Core Node: остановите захват или просмотр записи перед обновлением или удалением." >&2
exit 1
fi
fi
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
case "$mc_node_device_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
+6
View File
@@ -1,6 +1,12 @@
#!/bin/sh
set -eu
if [ -d /run/systemd/system ]; then
if [ -S /run/mission-core-sensors/driver.sock ]; then
if ! /usr/bin/python3 -I -c 'import http.client,json,socket; c=http.client.HTTPConnection("driver",timeout=5); c.sock=socket.socket(socket.AF_UNIX); c.sock.settimeout(5); c.sock.connect("/run/mission-core-sensors/driver.sock"); c.request("GET","/prepare-safe"); r=c.getresponse(); assert r.status==200 and json.load(r).get("safe") is True'; then
echo "Mission Core Node: остановите захват или просмотр записи перед обновлением или удалением." >&2
exit 1
fi
fi
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
case "$mc_node_device_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
@@ -0,0 +1,60 @@
"""udev-owned D455 IMU permission grant, limited to SDK capture controls."""
import grp
import os
import re
import sys
from pathlib import Path
def allowed_attributes(root):
names = [
"buffer/enable",
"buffer/length",
"buffer/watermark",
"current_timestamp_clock",
"in_accel_sampling_frequency",
"in_accel_hysteresis",
"in_anglvel_hysteresis",
"in_anglvel_sampling_frequency",
"scan_elements/in_timestamp_en",
]
names += [
f"scan_elements/in_{kind}_{axis}_en" for kind in ("accel", "anglvel") for axis in "xyz"
]
for name in names:
path = root / name
if path.exists() and not path.is_symlink() and path.resolve().is_relative_to(root):
yield path
def validate(sys_path, sys_root=Path("/sys")):
if not sys_path.startswith("/devices/") or ".." in sys_path.split("/"):
raise ValueError("Invalid sysfs path")
root = (sys_root / sys_path.lstrip("/")).resolve(strict=True)
if not root.is_relative_to(sys_root / "devices") or not re.fullmatch(r"iio:device[0-9]+", root.name):
raise ValueError("Not an IIO device")
matched = False
for parent in root.parents:
if (parent / "idVendor").exists() and (parent / "idProduct").exists():
matched = (parent / "idVendor").read_text().strip() == "8086" and (
parent / "idProduct"
).read_text().strip() == "0b5c"
break
if not matched:
raise ValueError("Not an admitted D455")
return root
def grant(sys_path):
root = validate(sys_path)
group = grp.getgrnam("mission-core-sensors").gr_gid
for path in allowed_attributes(root):
os.chown(path, 0, group, follow_symlinks=False)
os.chmod(path, 0o660, follow_symlinks=False)
if __name__ == "__main__":
if os.geteuid() != 0 or len(sys.argv) != 2:
sys.exit(1)
grant(sys.argv[1])
+83 -6
View File
@@ -1,11 +1,14 @@
"""Fixed model job. No paths, packages, URLs or commands are accepted from clients."""
import hashlib
import http.client
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import uuid
import zipfile
@@ -25,11 +28,12 @@ STEPS = [
def publish(value):
ROOT.mkdir(mode=0o755, exist_ok=True)
if ROOT.is_symlink() or ROOT.stat().st_uid != 0:
if ROOT.is_symlink() or ROOT.stat().st_uid != 0 or ROOT.stat().st_mode & 0o022:
raise RuntimeError("Небезопасный каталог драйверов")
ROOT.chmod(0o755)
tmp = ROOT / ".preparation.tmp"
with tmp.open("w") as f:
handle, name = tempfile.mkstemp(prefix=".preparation-", dir=ROOT)
tmp = Path(name)
with os.fdopen(handle, "w") as f:
os.fchmod(f.fileno(), 0o644)
json.dump(value, f, ensure_ascii=False)
f.flush()
@@ -57,12 +61,75 @@ def safe_members(archive):
yield info
def configure_imu_namespace():
# Isolated Python mode deliberately omits the script directory. This fixed,
# root-owned module is the same allowlist used by the udev grant.
import importlib.util
spec = importlib.util.spec_from_file_location(
"realsense_iio_access", Path(__file__).with_name("realsense_iio_access.py")
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
paths = []
for root in Path("/sys/bus/iio/devices").glob("iio:device*"):
resolved = root.resolve()
try:
module.validate(str(resolved)[4:])
except ValueError:
continue
paths.extend(str(p) for p in module.allowed_attributes(resolved))
# No subtree write grant. Only concrete allowlisted IIO attribute files
# belonging to detected D455s are admitted to the service mount namespace.
if any(" " in p or "\n" in p or "%" in p for p in paths):
raise RuntimeError("Неподдерживаемый путь IMU")
content = (
"[Service]\nReadWritePaths=\n"
+ "".join("ReadWritePaths=-" + p + "\n" for p in sorted(paths))
).encode()
folder = Path("/etc/systemd/system/mission-core-realsense.service.d")
folder.mkdir(exist_ok=True, mode=0o755)
destination = folder / "70-imu-access.conf"
fingerprint = ROOT / "imu-config.sha256"
previous = fingerprint.read_text() if fingerprint.exists() else None
if destination.is_symlink() or fingerprint.is_symlink():
raise RuntimeError("Конфликт настроек доступа IMU")
if destination.exists():
old = destination.read_bytes()
if old == content:
return False
if hashlib.sha256(old).hexdigest() != previous:
raise RuntimeError("Настройки IMU изменены в системе. Чужой файл сохранён.")
# Read-only preflight; every entry point shares the board's capture owner.
sock = Path("/run/mission-core-sensors/driver.sock")
if sock.exists():
client = http.client.HTTPConnection("driver", timeout=5)
client.sock = socket.socket(socket.AF_UNIX)
client.sock.settimeout(5)
try:
client.sock.connect(str(sock))
client.request("GET", "/prepare-safe")
response = client.getresponse()
if response.status != 200 or not json.loads(response.read(1024)).get("safe"):
raise RuntimeError("Остановите захват всех камер перед подготовкой драйвера.")
finally:
client.close()
destination.write_bytes(content)
destination.chmod(0o644)
fingerprint.write_text(hashlib.sha256(content).hexdigest())
fingerprint.chmod(0o644)
return True
def prepare():
manifest = json.loads((SHARE / "bundle.json").read_text())
revision = manifest["revision"]
if not revision.isalnum():
raise RuntimeError("Некорректная версия драйвера")
target = ROOT / revision
if target.is_symlink() or (ROOT / "active.path").is_symlink():
raise RuntimeError("Конфликт установленного драйвера")
imu_changed = False
state = {
"schema": "missioncore.node.device-preparation/v1",
"model_id": "realsense.d455",
@@ -129,10 +196,14 @@ def prepare():
(ROOT / "active.path").write_text(str(target))
(ROOT / "active.path").chmod(0o644)
elif step["id"] == "access":
imu_changed = configure_imu_namespace()
source = SHARE / "70-mission-core-realsense.rules"
dest = Path("/etc/udev/rules.d/70-mission-core-realsense.rules")
if dest.is_symlink() or (
dest.exists() and dest.read_bytes() != source.read_bytes()
dest.exists()
and dest.read_bytes() != source.read_bytes()
and hashlib.sha256(dest.read_bytes()).hexdigest()
!= "782eba7935400e688a7eaea53fe50d358a046eb6b2c99187a787cc03b0301449"
):
raise RuntimeError(
"Правила доступа к камере изменены в системе. Чужая конфигурация сохранена."
@@ -156,11 +227,17 @@ def prepare():
"--subsystem-match=video4linux",
)
run("/usr/bin/udevadm", "trigger", "--action=change", "--subsystem-match=hidraw")
run("/usr/bin/udevadm", "trigger", "--action=change", "--subsystem-match=iio")
run("/usr/bin/udevadm", "settle", "--timeout=10")
elif step["id"] == "service":
run("/usr/bin/systemctl", "enable", "mission-core-realsense.service")
# Never restart a running acquisition on repeated preparation.
run("/usr/bin/systemctl", "start", "mission-core-realsense.service")
# The fixed job refuses running capture before changing its namespace.
run("/usr/bin/systemctl", "daemon-reload")
run(
"/usr/bin/systemctl",
"restart" if imu_changed else "start",
"mission-core-realsense.service",
)
run("/usr/bin/systemctl", "is-active", "--quiet", "mission-core-realsense.service")
step["state"] = "complete"
publish(state)
@@ -0,0 +1,42 @@
import tempfile
import unittest
from pathlib import Path
from realsense_iio_access import allowed_attributes, validate
class IMUScopeTests(unittest.TestCase):
def test_only_capture_attributes_are_granted(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder).resolve()
for name in [
"buffer/enable",
"in_accel_hysteresis",
"scan_elements/in_accel_x_en",
"reset",
"power/control",
]:
path = root / name
path.parent.mkdir(exist_ok=True, parents=True)
path.touch()
(root / "buffer/length").symlink_to("/etc/passwd")
self.assertEqual(
{str(p.relative_to(root)) for p in allowed_attributes(root)},
{"buffer/enable", "in_accel_hysteresis", "scan_elements/in_accel_x_en"},
)
def test_foreign_usb_and_path_escape_are_rejected(self):
with tempfile.TemporaryDirectory() as folder:
sys = Path(folder).resolve()
usb = sys / "devices/usb/device"
root = usb / "hid/iio:device0"
root.mkdir(parents=True)
(usb / "idVendor").write_text("8086")
(usb / "idProduct").write_text("0b5c")
self.assertEqual(validate("/devices/usb/device/hid/iio:device0", sys), root)
(usb / "idProduct").write_text("ffff")
with self.assertRaises(ValueError):
validate("/devices/usb/device/hid/iio:device0", sys)
for path in ["/devices/../etc/passwd", "/class/iio:device0", "/devices/usb/device/hid"]:
with self.assertRaises(ValueError):
validate(path, sys)
+74 -12
View File
@@ -2,9 +2,11 @@
import hashlib
import json
import logging
import math
import os
import queue
import re
import shutil
import threading
import time
@@ -18,7 +20,7 @@ from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
MODEL = {
"plugin_id": "missioncore.realsense",
"plugin_version": "0.6.0",
"plugin_version": "0.6.6",
"model_id": "realsense.d455",
}
@@ -44,10 +46,19 @@ def kind(profile):
return str(profile.stream_type()).split(".")[-1]
def configure_profiles(config, profiles):
for p in profiles:
stream, fmt = getattr(rs.stream, p["stream"]), getattr(rs.format, p["format"])
if "width" in p:
config.enable_stream(stream, p["index"], p["width"], p["height"], fmt, p["fps"])
else:
config.enable_stream(stream, p["index"], fmt, p["fps"])
class Device:
def __init__(self, serial, root, execution):
def __init__(self, serial, root, execution, usb_serial=None):
self.serial = serial
self.id = device_id(serial)
self.id = device_id(usb_serial or serial)
self.root = root / self.id
self.root.mkdir(exist_ok=True, mode=0o700)
self.lock = threading.RLock()
@@ -69,6 +80,7 @@ class Device:
self.frames = {}
self.last_frame = None
self.record = None
self.playback_id = None
self.profiles = []
self.options = []
self.sdk_device = None
@@ -91,6 +103,10 @@ class Device:
def refresh(self, dev):
with self.lock:
self.sdk_device = dev
if not self.online:
self.verified_this_process = False
self.session = "sensor_" + uuid.uuid4().hex
self.opened = utc()
self.online = True
self.firmware = dev.get_info(rs.camera_info.firmware_version)
self.transport = dev.get_info(rs.camera_info.usb_type_descriptor)
@@ -204,6 +220,7 @@ class Device:
"frames": dict(self.frames),
"last_frame": self.last_frame,
"recording": self.record,
"playback_id": self.playback_id,
"layers": list(self.images)
+ (["points"] if self.depth is not None else [])
+ (["motion"] if self.motion else []),
@@ -253,15 +270,11 @@ class Device:
raise ValueError("Выберите хотя бы один видеопоток.")
config = rs.config()
config.enable_device(self.serial)
for p in profiles:
stream, fmt = getattr(rs.stream, p["stream"]), getattr(rs.format, p["format"])
if "width" in p:
config.enable_stream(stream, p["index"], p["width"], p["height"], fmt, p["fps"])
else:
config.enable_stream(stream, p["index"], fmt, p["fps"])
configure_profiles(config, profiles)
pipeline = rs.pipeline()
if not config.can_resolve(rs.pipeline_wrapper(pipeline)):
raise ValueError("Камера не поддерживает эту комбинацию профилей. Выберите другую.")
self.playback_id = None
self.acquisition = "starting"
self.revision += 1
self.images, self.motion, self.frames = {}, {}, {}
@@ -276,7 +289,7 @@ class Device:
ident = "capture_" + uuid.uuid4().hex
record_path = self.root / "recordings" / ident
record_path.mkdir(parents=True, mode=0o700)
config.enable_record_to_file(str(record_path / "source.bag"))
config.enable_record_to_file(str(record_path / "source.db3"))
self.record = {
"id": ident,
"state": "recording",
@@ -287,6 +300,7 @@ class Device:
"profiles": profiles,
"firmware": self.firmware,
"sdk": "2.58.4.10922",
"storage_format": "rosbag2-sqlite3",
"options": self.options,
}
atomic(record_path / "manifest.json", self.record)
@@ -320,7 +334,8 @@ class Device:
atomic(record_path / "manifest.json", self.record)
self.thread = threading.Thread(target=self.consume, daemon=True)
self.thread.start()
except Exception:
except Exception as error:
logging.error("D455 capture: %s", str(error).replace(self.serial, "[camera]"))
with suppress(RuntimeError):
pipeline.stop()
self.pipeline = None
@@ -332,6 +347,49 @@ class Device:
self.record = None
raise ValueError(self.message) from None
def replay(self, ident):
# Only completed board-owned recordings; UI never supplies a filesystem path.
if not isinstance(ident, str) or not re.fullmatch(r"capture_[0-9a-f]{32}", ident):
raise ValueError("Некорректная запись.")
with self.lock:
if self.pipeline is not None:
raise ValueError("Сначала остановите текущий захват или просмотр записи.")
directory = self.root / "recordings" / ident
source, manifest = directory / "source.db3", directory / "manifest.json"
if directory.is_symlink() or source.is_symlink() or manifest.is_symlink():
raise ValueError("Запись недоступна.")
if not source.is_file() or not manifest.is_file():
raise ValueError("Запись не найдена.")
value = json.loads(manifest.read_text())
if value.get("state") != "complete" or source.stat().st_size != value.get("bytes"):
raise ValueError("Запись не завершена или повреждена.")
config, pipeline = rs.config(), rs.pipeline()
config.enable_device_from_file(str(source), repeat_playback=True)
configure_profiles(config, value["profiles"])
self.images, self.motion, self.frames = {}, {}, {}
self.depth, self.last_frame = None, None
self.queue = queue.Queue(maxsize=2)
self.stop_event.clear()
self.acquisition = "starting"
try:
active = pipeline.start(config, self.callback)
self.pipeline = pipeline
active.get_device().as_playback().set_real_time(True)
self.depth_scale = active.get_device().first_depth_sensor().get_depth_scale()
self.playback_id = ident
self.acquisition = "streaming"
self.message = ""
self.thread = threading.Thread(target=self.consume, daemon=True)
self.thread.start()
except RuntimeError:
with suppress(RuntimeError):
pipeline.stop()
self.pipeline = None
self.acquisition = "failed"
raise ValueError("Не удалось открыть исходную запись.") from None
self.revision += 1
return {"ok": True, "playback_id": ident}
def consume(self):
colorizer = rs.colorizer()
last_data, last_disk = time.monotonic(), time.monotonic()
@@ -387,8 +445,10 @@ class Device:
if pipeline is not None:
self.acquisition = "stopping"
pipeline.stop()
del pipeline
if self.thread and not from_capture:
self.thread.join(timeout=3)
self.playback_id = None
self.acquisition = "failed" if failed else "idle"
self.revision += 1
if self.record:
@@ -400,7 +460,7 @@ class Device:
frames=dict(self.frames),
)
path = self.root / "recordings" / value["id"]
source = path / "source.bag"
source = path / "source.db3"
if source.exists():
digest = hashlib.sha256()
with source.open("rb") as f:
@@ -465,6 +525,8 @@ class Device:
or not item["min"] <= value <= item["max"]
):
raise ValueError("Параметр недоступен или значение вне диапазона.")
if self.playback_id:
raise ValueError("Остановите просмотр записи перед настройкой камеры.")
sensor_index, option_id = map(int, identifier.split(":"))
sensor = self.sdk_device.query_sensors()[sensor_index]
option = rs.option(option_id)
+9 -3
View File
@@ -5,6 +5,7 @@ import ipaddress
import json
import time
import uuid
from fractions import Fraction
import aioice.ice
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
@@ -35,11 +36,16 @@ class CameraTrack(VideoStreamTrack):
def __init__(self, device, layer):
super().__init__()
self.device, self.layer = device, layer
self.started = None
self.sequence = 0
async def recv(self):
pts, base = await self.next_timestamp()
# Bound preview to 15 Hz; hardware profiles and raw recording are independent.
await asyncio.sleep(1 / 30)
# Fixed 15 Hz preview clock; raw hardware timing is independent.
if self.started is None:
self.started = time.monotonic()
await asyncio.sleep(max(0, self.started + self.sequence / 15 - time.monotonic()))
pts, base = self.sequence * 6000, Fraction(1, 90000)
self.sequence += 1
while self.layer not in self.device.images:
await asyncio.sleep(0.1)
frame = VideoFrame.from_ndarray(self.device.images[self.layer], format="rgb24")
+35 -2
View File
@@ -37,17 +37,35 @@ class Host:
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)
ident = device_id(serial)
# 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)
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.online = False
device.verified_this_process = False
await asyncio.to_thread(work)
@@ -112,6 +130,8 @@ class Host:
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":
@@ -128,8 +148,20 @@ class Host:
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)
@@ -154,6 +186,7 @@ def main():
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():
@@ -77,3 +77,38 @@ Node/Core, переименование, глазик, профили, дейс
но не заменяют её. Реальные эксперименты имеют приватный manifest с UTC,
monotonic, границами и SHA-256. Чистая установка остаётся отложенным владельцем
критерием; текущий подготовленный Mini не выдаётся за чистую систему.
## Уточнения реализации и UI от 05.09.2026
Подготовка состоит из пяти сохраняемых системных этапов и шестой SDK-проверки
цвета, глубины, двух ИК-каналов, акселерометра и гироскопа. Общий ProgressBar
Design Guideline показывает завершённые этапы между названием и тремя кнопками,
не дублирует круг и точку. Отчёт прошлого запуска не используется как текущий
прогресс. Статус до подготовки: «Требуется подготовка». Характеристики БК
используют компактный размер описания аппарата и приглушённый цвет значений.
USB serial корпуса и serial SDK-модуля D455 различаются. Идентификатор берётся
по реальному USB-предку physical_port SDK; перечень USB и SDK объединяется по
нему, без привязки к порядку или единственной камере.
IIO IMU требует записи в capture controls sysfs. Профиль допускает только
D455 VID/PID, существующие buffer/enable,length,watermark, sampling_frequency,
hysteresis, current_timestamp_clock и scan_elements *_en. Отдельный root helper
назначает этим файлам группу сервиса и 0660; systemd получает ReadWritePaths
ровно этих файлов. Записи на всё /sys/devices нет. При смене USB-топологии
повторная кнопка подготовки пересобирает профиль. Чужая конфигурация сохраняется
с ошибкой конфликта. Обновление и удаление пакета отказывают, пока идёт захват,
просмотр записи или подготовка.
SDK 2.58.4.10922 записывает `source.db3` (ROS2 rosbag2/SQLite), а не legacy .bag.
Payload SDK содержит необходимые библиотеки, отдельная ROS2 не устанавливается.
Приватный manifest сохраняет выбранные профили, параметры, калибровку, firmware,
UTC/monotonic timestamps, счётчики и SHA-256 завершённого файла. Просмотр записи
явно запускается глазиком в списке записей, повторяется до остановки и использует
тот же WebRTC/слои; физическую камеру для этого не запускает. В API передаётся
только ID записи, произвольный путь не принимается. Текущий живой захват сначала
нужно остановить.
Первый тест исходной записи обнаружил отказ SDK от расширения .bag; это
исправлено в профиле 0.6.6 и требует повторной аппаратной проверки. SDK
[документирует формат .db3](https://github.com/realsenseai/librealsense/blob/master/doc/record-and-playback.md).
+4 -3
View File
@@ -9,10 +9,11 @@ export function LiveViewport({device,layer,transport,failure}:{device:Sensor;lay
useEffect(()=>{
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
let cancelled=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();
setTelemetry({});setState('Подключаем просмотр');
const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
pc.ontrack=event=>{if(video.current)video.current.srcObject=new MediaStream([event.track]);};
pc.onconnectionstatechange=()=>{if(!cancelled)setState(pc.connectionState==='connected'?'Прямой эфир':pc.connectionState==='failed'?'Нет связи с камерой':'Подключаем просмотр');};
pc.onconnectionstatechange=()=>{if(!cancelled)setState(pc.connectionState==='connected'?(device.playback_id?'Исходная запись':'Прямой эфир'):pc.connectionState==='failed'?'Нет связи с камерой':'Подключаем просмотр');};
channel.onmessage=event=>{try{const v=JSON.parse(event.data);last=Date.now();setTelemetry(v);}catch{ /* Ignore malformed preview, not control state. */ }};
timer=setInterval(()=>{if(channel.readyState==='open')channel.send('keepalive');if(Date.now()-last>8000)setState('Нет свежих кадров');},2000);
async function connect(){try{
@@ -24,10 +25,10 @@ export function LiveViewport({device,layer,transport,failure}:{device:Sensor;lay
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
}catch(e){if(!cancelled){setState('Просмотр недоступен');failure(e);}}}
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,layer,transport]);
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
const active=device.snapshot.acquisition==='streaming';
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer)} actions={<><StatusBadge tone={active&&state==='Прямой эфир'?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer)} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
{!active?<p>Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.</p>:<>
{state==='Подключаем просмотр'&&<ActivityIndicator label="Открываем частный канал камеры"/>}
{layer==='points'?<PointViewport points={telemetry.points??[]}/>:layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,v])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} className="sensor-media" autoPlay muted playsInline/>}
+9 -7
View File
@@ -1,26 +1,28 @@
import {useEffect,useState} from 'react';
import {ActivityIndicator,Button,Select,SettingsCard,TextField} from '@nodedc/ui-react';
import {ActivityIndicator,Button,Icon,IconButton,Select,SettingsCard,TextField} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorTransport} from './contracts';
import {LiveViewport} from './LiveViewport';
export function SensorDetail({device,transport,back,refresh,failure}:{device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pending,setPending]=useState(false);const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]);
async function act(action:string,parameters:Record<string,unknown>={}){if(pending)return;setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}}
const groups=[...new Set((detail?.profiles??[]).map(p=>p.stream+':'+p.index))];
async function act(action:string,parameters:Record<string,unknown>={}){if(pending)return;failure(null);setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}}
const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index)));
const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))];
const videoNames:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Точки',motion:'Движение'};
const available=[...new Set((detail?.profiles??[]).map(p=>p.stream==='infrared'?p.stream+p.index:p.stream==='gyro'||p.stream==='accel'?'motion':p.stream))];if(available.includes('depth'))available.push('points');
const available=[...new Set(supported.map(p=>p.stream==='infrared'?p.stream+p.index:p.stream==='gyro'||p.stream==='accel'?'motion':p.stream))];if(available.includes('depth'))available.push('points');
const currentOption=detail?.options?.find(v=>v.id===option);const active=device.snapshot.acquisition==='streaming'||device.snapshot.acquisition==='starting';
return <div className="sensor-content"><div><Button onClick={back}>К устройствам</Button></div>
<SettingsCard title={device.name} description={`${device.model} · USB ${device.usb}${device.firmware?' · '+device.firmware:''}`}>
{device.snapshot.message&&<p>{device.snapshot.message}</p>}
<div className="sensor-actions"><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={pending||(!active&&device.snapshot.acquisition!=='failed')} onClick={()=>void act('stop')}>Остановить захват</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
<div className="sensor-actions"><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={pending||(!active&&device.snapshot.acquisition!=='failed')} onClick={()=>void act('stop')}>{device.playback_id?'Закрыть запись':'Остановить захват'}</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
{device.playback_id&&<p>Просмотр исходной записи · повтор. Камера для него не запускается.</p>}
{device.recording&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
</SettingsCard>
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={pending||active}/>)}</div></SettingsCard>
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min}${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={pending||currentOption.read_only}/><Button disabled={pending||currentOption.read_only||optionValue===''} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min}${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={pending||currentOption.read_only||!!device.playback_id}/><Button disabled={pending||currentOption.read_only||!!device.playback_id||optionValue===''} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={pending||active||item.state!=='complete'} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
</div>;
}
+8 -6
View File
@@ -3,20 +3,22 @@ import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,Settin
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
import {SensorDetail} from './SensorDetail';
import './sensors.css';
export function SensorWorkspace({transport,enabled=true}:{transport:SensorTransport;enabled?:boolean}){
export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void}){
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<string|null>(null);const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
const failure=useCallback((e:unknown)=>{setError(e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{setInventory(await transport.inventory());setFresh(true);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
useEffect(()=>{void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>clearInterval(timer);},[refresh]);
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
const device=inventory?.items.find(v=>v.id===selected);
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
return <div className="sensor-workspace">{device?<SensorDetail device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure}/>:<>
<div className="sensor-actions"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>void refresh()}><Icon name="refresh"/></IconButton></div>
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:inventory.items.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите камеру к бортовому компьютеру."/>:<ResourceList aria-label="Устройства БК">{inventory.items.map(item=>{
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':'Идёт захват':item.verified?'Проверено':item.prepared?'Драйвер установлен':'Нужно подготовить';
return <li key={item.id}><ResourceRow icon={<Icon name="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} aria-busy={busy} status={<StatusBadge tone={item.verified&&item.online?'success':'neutral'}>{busy?<ActivityIndicator label={label}/>:item.verified&&item.online?<Icon name="check" label={label}/>:label}</StatusBadge>} actions={<><IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.snapshot.acquisition==='streaming'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton><IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
{(localBusy||inventory?.operations?.some(v=>v.state==='running'))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>)}</SettingsCard>}
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':item.playback_id?'Просмотр записи':'Идёт захват':item.verified?'Проверено':item.prepared?'Драйвер установлен':'Требуется подготовка';
return <li key={item.id}><ResourceRow icon={<Icon name="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge tone={item.verified&&item.online?'success':'neutral'}>{item.verified&&item.online?<Icon name="check" label={label}/>:label}</StatusBadge>} actions={<><IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.snapshot.acquisition==='streaming'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton><IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
</>}
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/></Window>
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
+3 -3
View File
@@ -1,15 +1,15 @@
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
export interface Sensor {
id: string; name: string; model: string; prepared: boolean; verified: boolean; online: boolean; usb: string; firmware?: string;
id: string; name: string; model: string; prepared: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
frames?: Record<string,number>; last_frame?: {observed_at:string}; recording?: {id: string};
recordings?: {id:string;state:string;started_at:string;bytes?:number;sha256?:string}[];
}
export interface SensorInventory {
items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;state:string;error?:string}[];
preparation?: {state:string;steps:{id:string;label:string;state:string;message?:string}[]};
items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;requested_at:string;state:string;error?:string}[];
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
}
export interface SensorCommand {
api_version: 'missioncore.nodedc/plugin-sdk/v0alpha2'; kind:'OperationRequest'; operation_id:string;
+1 -1
View File
@@ -5,6 +5,6 @@
.sensor-facts {display:grid;gap:var(--nodedc-space-4);font-size:var(--nodedc-font-size-sm)}
.sensor-facts dd {margin:0}
.sensor-media {display:block;width:100%;height:360px;object-fit:contain;touch-action:none}
.sensor-viewer-expanded {position:fixed;inset:var(--nodedc-space-4);z-index:200;background:var(--nodedc-surface-1);overflow:auto}
.sensor-viewer-expanded {position:fixed;inset:var(--nodedc-space-4);z-index:var(--nodedc-layer-overlay);background:var(--nodedc-canvas);overflow:auto}
.sensor-viewer-expanded .sensor-media {height:calc(100vh - 190px)}
.sensor-record {display:flex;flex-wrap:wrap;gap:var(--nodedc-space-4);padding-block:var(--nodedc-space-3);font-size:var(--nodedc-font-size-sm)}
+1
View File
@@ -15,6 +15,7 @@ ACTIONS = {
"rename",
"verify",
"start",
"replay",
"stop",
"option",
"offer",