fix(node): verify D455 access and share sensor progress and recording UI
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user