diff --git a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx index a04020d..5dbbd7f 100644 --- a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx +++ b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx @@ -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(()=>({ 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 ; + return ; } diff --git a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx index d37b605..1406b4e 100644 --- a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx +++ b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx @@ -18,6 +18,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe const [pending, setPending] = useState(false); const [error, setError] = useState(""); const [selected, setSelected] = useState(null); + const [sensorOpen, setSensorOpen] = useState(false); const [revoking, setRevoking] = useState(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 &&

{fleet.error}

} {!adding && error &&

{error}

} {detail ? <> -
- {fleet.error ? "Нет свежих данных" : statusLabel(detail)}}> +
+ {!sensorOpen && {fleet.error ? "Нет свежих данных" : statusLabel(detail)}}> {detail.notice &&

{detail.notice}

}
Бортовой компьютер
{detail.node_id}
Последняя связь
{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}
{detail.host && <>
Имя БК в системе
{detail.host.hostname}
Операционная система
{detail.host.os}
Архитектура
{detail.host.architecture}
Процессоры
{detail.host.cpus}
Память
{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}
}
{detail.enrollment !== "revoked" && } -
- +
} + : !fleet.items ? : fleet.items.length === 0 ? : {fleet.items.map(item =>
  • } title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={{fleet.error ? "Нет свежих данных" : statusLabel(item)}} actions={ setSelected(item.id)}>} />
  • )}
    } {preview ? : }}>
    diff --git a/apps/control-station/src/workspaces/fleet/fleet.css b/apps/control-station/src/workspaces/fleet/fleet.css index c47eb40..aef8786 100644 --- a/apps/control-station/src/workspaces/fleet/fleet.css +++ b/apps/control-station/src/workspaces/fleet/fleet.css @@ -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); } diff --git a/apps/node-agent/internal/node/sensors.go b/apps/node-agent/internal/node/sensors.go index ef1c6e2..48c2f9b 100644 --- a/apps/node-agent/internal/node/sensors.go +++ b/apps/node-agent/internal/node/sensors.go @@ -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") diff --git a/apps/node-agent/internal/node/sensors_test.go b/apps/node-agent/internal/node/sensors_test.go index ea6833b..ffc7710 100644 --- a/apps/node-agent/internal/node/sensors_test.go +++ b/apps/node-agent/internal/node/sensors_test.go @@ -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") + } + }) + } +} diff --git a/apps/node-agent/packaging/70-mission-core-realsense.rules b/apps/node-agent/packaging/70-mission-core-realsense.rules index b0281d2..e8f6c09 100644 --- a/apps/node-agent/packaging/70-mission-core-realsense.rules +++ b/apps/node-agent/packaging/70-mission-core-realsense.rules @@ -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" diff --git a/apps/node-agent/packaging/build.py b/apps/node-agent/packaging/build.py index 814b5e6..dc29c65 100644 --- a/apps/node-agent/packaging/build.py +++ b/apps/node-agent/packaging/build.py @@ -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(): diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index bc6a9f5..c3a9eb9 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -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)) diff --git a/apps/node-agent/packaging/mission-core-realsense.service b/apps/node-agent/packaging/mission-core-realsense.service index 0133ac3..6ec6ffa 100644 --- a/apps/node-agent/packaging/mission-core-realsense.service +++ b/apps/node-agent/packaging/mission-core-realsense.service @@ -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 diff --git a/apps/node-agent/packaging/postinst b/apps/node-agent/packaging/postinst index 65e5b57..1bb0274 100644 --- a/apps/node-agent/packaging/postinst +++ b/apps/node-agent/packaging/postinst @@ -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 diff --git a/apps/node-agent/packaging/preinst b/apps/node-agent/packaging/preinst index 579fb70..b49cb8d 100644 --- a/apps/node-agent/packaging/preinst +++ b/apps/node-agent/packaging/preinst @@ -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 ;; diff --git a/apps/node-agent/packaging/prerm b/apps/node-agent/packaging/prerm index 7c86ed7..08d783d 100644 --- a/apps/node-agent/packaging/prerm +++ b/apps/node-agent/packaging/prerm @@ -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 ;; diff --git a/apps/node-agent/packaging/realsense_iio_access.py b/apps/node-agent/packaging/realsense_iio_access.py new file mode 100644 index 0000000..bd43f88 --- /dev/null +++ b/apps/node-agent/packaging/realsense_iio_access.py @@ -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]) diff --git a/apps/node-agent/packaging/realsense_prepare.py b/apps/node-agent/packaging/realsense_prepare.py index fbeb2f6..4effe97 100644 --- a/apps/node-agent/packaging/realsense_prepare.py +++ b/apps/node-agent/packaging/realsense_prepare.py @@ -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) diff --git a/apps/node-agent/packaging/test_realsense_iio_access.py b/apps/node-agent/packaging/test_realsense_iio_access.py new file mode 100644 index 0000000..dc12fa7 --- /dev/null +++ b/apps/node-agent/packaging/test_realsense_iio_access.py @@ -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) diff --git a/apps/node-agent/sensors/device.py b/apps/node-agent/sensors/device.py index 36cc8ed..8324520 100644 --- a/apps/node-agent/sensors/device.py +++ b/apps/node-agent/sensors/device.py @@ -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) diff --git a/apps/node-agent/sensors/media.py b/apps/node-agent/sensors/media.py index b229be6..5b988d5 100644 --- a/apps/node-agent/sensors/media.py +++ b/apps/node-agent/sensors/media.py @@ -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") diff --git a/apps/node-agent/sensors/server.py b/apps/node-agent/sensors/server.py index 1224c46..e704c47 100644 --- a/apps/node-agent/sensors/server.py +++ b/apps/node-agent/sensors/server.py @@ -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(): diff --git a/docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md b/docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md index 4666ef2..a04b73b 100644 --- a/docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md +++ b/docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md @@ -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). diff --git a/packages/sensor-ui/src/LiveViewport.tsx b/packages/sensor-ui/src/LiveViewport.tsx index aaf3171..3c949e2 100644 --- a/packages/sensor-ui/src/LiveViewport.tsx +++ b/packages/sensor-ui/src/LiveViewport.tsx @@ -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|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
    {state}setExpanded(!expanded)}>}> + return
    {state}setExpanded(!expanded)}>}> {!active?

    Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.

    :<> {state==='Подключаем просмотр'&&} {layer==='points'?:layer==='motion'?
    {Object.entries(telemetry.motion??{}).map(([kind,v])=>
    {kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}
    X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}
    )}
    :
    -
    ({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<>setOptionValue(e.target.value)} disabled={pending||currentOption.read_only||!!device.playback_id}/>}
    + {detail.recordings?.length?detail.recordings.map(item=>
    {new Date(item.started_at).toLocaleString('ru-RU')}{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}void act('replay',{recording_id:item.id})}>
    ):

    Записей пока нет.

    }
    } {!detail&&

    {device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}

    }
    ; } diff --git a/packages/sensor-ui/src/SensorWorkspace.tsx b/packages/sensor-ui/src/SensorWorkspace.tsx index e817f3a..191c841 100644 --- a/packages/sensor-ui/src/SensorWorkspace.tsx +++ b/packages/sensor-ui/src/SensorWorkspace.tsx @@ -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(null);const [selected,setSelected]=useState(null);const [editing,setEditing]=useState(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState(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={}){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={}){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
    {device?setSelected(null)} refresh={refresh} failure={failure}/>:<>
    {!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}void refresh()}>
    {!inventory?:inventory.items.length===0?:{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
  • } title={item.name} description={item.model} metadata={USB {item.usb}} aria-busy={busy} status={{busy?:item.verified&&item.online?:label}} actions={<>void action(item,'prepare')}>{setEditing(item);setName(item.name);}}>setSelected(item.id)}>}/>
  • ;})}
    } - {(localBusy||inventory?.operations?.some(v=>v.state==='running'))&&inventory?.preparation&&{inventory.preparation.steps.map(step=>:step.state==='running'?:{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}}/>)}} + 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
  • } title={item.name} description={item.model} metadata={USB {item.usb}} 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={{item.verified&&item.online?:label}} actions={<>void action(item,'prepare')}>{setEditing(item);setName(item.name);}}>setSelected(item.id)}>}/>
  • ;})}} + {(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&&{inventory.preparation.steps.map(step=>:step.state==='running'?:{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}}/>) }:Ожидает}/>} } setEditing(null)} footer={}>setName(e.target.value)} disabled={!!localBusy}/> setError('')}/> diff --git a/packages/sensor-ui/src/contracts.ts b/packages/sensor-ui/src/contracts.ts index 728fd6d..bd309ae 100644 --- a/packages/sensor-ui/src/contracts.ts +++ b/packages/sensor-ui/src/contracts.ts @@ -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; 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; diff --git a/packages/sensor-ui/src/sensors.css b/packages/sensor-ui/src/sensors.css index 4490c82..5f66d59 100644 --- a/packages/sensor-ui/src/sensors.css +++ b/packages/sensor-ui/src/sensors.css @@ -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)} diff --git a/src/k1link/fleet/sensors.py b/src/k1link/fleet/sensors.py index 29921ac..2b56a8b 100644 --- a/src/k1link/fleet/sensors.py +++ b/src/k1link/fleet/sensors.py @@ -15,6 +15,7 @@ ACTIONS = { "rename", "verify", "start", + "replay", "stop", "option", "offer",