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
+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():