fix(node): verify D455 access and share sensor progress and recording UI
This commit is contained in:
@@ -2,11 +2,11 @@ import {useMemo} from 'react';
|
||||
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||
export function VehicleSensors({vehicleID,enabled}:{vehicleID:string;enabled:boolean}){
|
||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||
const transport=useMemo<SensorTransport>(()=>({
|
||||
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return value.sensor_state;},
|
||||
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||
}),[vehicleID]);
|
||||
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled}/>;
|
||||
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [sensorOpen, setSensorOpen] = useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
const lastCreateRequest = useRef(createRequest);
|
||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
|
||||
@@ -43,16 +44,16 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
{!adding && error && <p role="alert">{error}</p>}
|
||||
{detail ? <>
|
||||
<div><Button onClick={() => setSelected(null)}>К списку аппаратов</Button></div>
|
||||
<SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
<div><Button onClick={() => {setSelected(null);setSensorOpen(false);}}>К списку аппаратов</Button></div>
|
||||
{!sensorOpen && <SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
{detail.notice && <p role="status">{detail.notice}</p>}
|
||||
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
|
||||
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
|
||||
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.fleet-facts { font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
|
||||
.fleet-facts dt { color: var(--nodedc-text-secondary); }
|
||||
.fleet-facts dd { color: var(--nodedc-text-muted); }
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -9,10 +9,11 @@ export function LiveViewport({device,layer,transport,failure}:{device:Sensor;lay
|
||||
useEffect(()=>{
|
||||
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||
let cancelled=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();
|
||||
setTelemetry({});setState('Подключаем просмотр');
|
||||
const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
|
||||
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
|
||||
pc.ontrack=event=>{if(video.current)video.current.srcObject=new MediaStream([event.track]);};
|
||||
pc.onconnectionstatechange=()=>{if(!cancelled)setState(pc.connectionState==='connected'?'Прямой эфир':pc.connectionState==='failed'?'Нет связи с камерой':'Подключаем просмотр');};
|
||||
pc.onconnectionstatechange=()=>{if(!cancelled)setState(pc.connectionState==='connected'?(device.playback_id?'Исходная запись':'Прямой эфир'):pc.connectionState==='failed'?'Нет связи с камерой':'Подключаем просмотр');};
|
||||
channel.onmessage=event=>{try{const v=JSON.parse(event.data);last=Date.now();setTelemetry(v);}catch{ /* Ignore malformed preview, not control state. */ }};
|
||||
timer=setInterval(()=>{if(channel.readyState==='open')channel.send('keepalive');if(Date.now()-last>8000)setState('Нет свежих кадров');},2000);
|
||||
async function connect(){try{
|
||||
@@ -24,10 +25,10 @@ export function LiveViewport({device,layer,transport,failure}:{device:Sensor;lay
|
||||
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
|
||||
}catch(e){if(!cancelled){setState('Просмотр недоступен');failure(e);}}}
|
||||
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
|
||||
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,layer,transport]);
|
||||
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
|
||||
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
|
||||
const active=device.snapshot.acquisition==='streaming';
|
||||
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer)} actions={<><StatusBadge tone={active&&state==='Прямой эфир'?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
||||
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer)} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
||||
{!active?<p>Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.</p>:<>
|
||||
{state==='Подключаем просмотр'&&<ActivityIndicator label="Открываем частный канал камеры"/>}
|
||||
{layer==='points'?<PointViewport points={telemetry.points??[]}/>:layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,v])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} className="sensor-media" autoPlay muted playsInline/>}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {ActivityIndicator,Button,Select,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||
import {ActivityIndicator,Button,Icon,IconButton,Select,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {LiveViewport} from './LiveViewport';
|
||||
export function SensorDetail({device,transport,back,refresh,failure}:{device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
|
||||
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pending,setPending]=useState(false);const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
|
||||
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
|
||||
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]);
|
||||
async function act(action:string,parameters:Record<string,unknown>={}){if(pending)return;setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}}
|
||||
const groups=[...new Set((detail?.profiles??[]).map(p=>p.stream+':'+p.index))];
|
||||
async function act(action:string,parameters:Record<string,unknown>={}){if(pending)return;failure(null);setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}}
|
||||
const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index)));
|
||||
const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))];
|
||||
const videoNames:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Точки',motion:'Движение'};
|
||||
const available=[...new Set((detail?.profiles??[]).map(p=>p.stream==='infrared'?p.stream+p.index:p.stream==='gyro'||p.stream==='accel'?'motion':p.stream))];if(available.includes('depth'))available.push('points');
|
||||
const available=[...new Set(supported.map(p=>p.stream==='infrared'?p.stream+p.index:p.stream==='gyro'||p.stream==='accel'?'motion':p.stream))];if(available.includes('depth'))available.push('points');
|
||||
const currentOption=detail?.options?.find(v=>v.id===option);const active=device.snapshot.acquisition==='streaming'||device.snapshot.acquisition==='starting';
|
||||
return <div className="sensor-content"><div><Button onClick={back}>К устройствам</Button></div>
|
||||
<SettingsCard title={device.name} description={`${device.model} · USB ${device.usb}${device.firmware?' · '+device.firmware:''}`}>
|
||||
{device.snapshot.message&&<p>{device.snapshot.message}</p>}
|
||||
<div className="sensor-actions"><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={pending||(!active&&device.snapshot.acquisition!=='failed')} onClick={()=>void act('stop')}>Остановить захват</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
|
||||
<div className="sensor-actions"><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={pending||(!active&&device.snapshot.acquisition!=='failed')} onClick={()=>void act('stop')}>{device.playback_id?'Закрыть запись':'Остановить захват'}</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
|
||||
{device.playback_id&&<p>Просмотр исходной записи · повтор. Камера для него не запускается.</p>}
|
||||
{device.recording&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
||||
</SettingsCard>
|
||||
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
|
||||
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={pending||active}/>)}</div></SettingsCard>
|
||||
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={pending||currentOption.read_only}/><Button disabled={pending||currentOption.read_only||optionValue===''} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
||||
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
||||
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={pending||currentOption.read_only||!!device.playback_id}/><Button disabled={pending||currentOption.read_only||!!device.playback_id||optionValue===''} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
||||
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={pending||active||item.state!=='complete'} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
||||
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -3,20 +3,22 @@ import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,Settin
|
||||
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||
import {SensorDetail} from './SensorDetail';
|
||||
import './sensors.css';
|
||||
export function SensorWorkspace({transport,enabled=true}:{transport:SensorTransport;enabled?:boolean}){
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void}){
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<string|null>(null);const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||
const failure=useCallback((e:unknown)=>{setError(e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{setInventory(await transport.inventory());setFresh(true);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
|
||||
useEffect(()=>{void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>clearInterval(timer);},[refresh]);
|
||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
|
||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
|
||||
const device=inventory?.items.find(v=>v.id===selected);
|
||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||
return <div className="sensor-workspace">{device?<SensorDetail device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure}/>:<>
|
||||
<div className="sensor-actions"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>void refresh()}><Icon name="refresh"/></IconButton></div>
|
||||
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:inventory.items.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите камеру к бортовому компьютеру."/>:<ResourceList aria-label="Устройства БК">{inventory.items.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
||||
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':'Идёт захват':item.verified?'Проверено':item.prepared?'Драйвер установлен':'Нужно подготовить';
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} aria-busy={busy} status={<StatusBadge tone={item.verified&&item.online?'success':'neutral'}>{busy?<ActivityIndicator label={label}/>:item.verified&&item.online?<Icon name="check" label={label}/>:label}</StatusBadge>} actions={<><IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.snapshot.acquisition==='streaming'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton><IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
||||
{(localBusy||inventory?.operations?.some(v=>v.state==='running'))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>)}</SettingsCard>}
|
||||
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
||||
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':item.playback_id?'Просмотр записи':'Идёт захват':item.verified?'Проверено':item.prepared?'Драйвер установлен':'Требуется подготовка';
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge tone={item.verified&&item.online?'success':'neutral'}>{item.verified&&item.online?<Icon name="check" label={label}/>:label}</StatusBadge>} actions={<><IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.snapshot.acquisition==='streaming'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton><IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
||||
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
|
||||
</>}
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/></Window>
|
||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
|
||||
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
||||
export interface Sensor {
|
||||
id: string; name: string; model: string; prepared: boolean; verified: boolean; online: boolean; usb: string; firmware?: string;
|
||||
id: string; name: string; model: string; prepared: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
||||
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
||||
frames?: Record<string,number>; last_frame?: {observed_at:string}; recording?: {id: string};
|
||||
recordings?: {id:string;state:string;started_at:string;bytes?:number;sha256?:string}[];
|
||||
}
|
||||
export interface SensorInventory {
|
||||
items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;state:string;error?:string}[];
|
||||
preparation?: {state:string;steps:{id:string;label:string;state:string;message?:string}[]};
|
||||
items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;requested_at:string;state:string;error?:string}[];
|
||||
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
|
||||
}
|
||||
export interface SensorCommand {
|
||||
api_version: 'missioncore.nodedc/plugin-sdk/v0alpha2'; kind:'OperationRequest'; operation_id:string;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -15,6 +15,7 @@ ACTIONS = {
|
||||
"rename",
|
||||
"verify",
|
||||
"start",
|
||||
"replay",
|
||||
"stop",
|
||||
"option",
|
||||
"offer",
|
||||
|
||||
Reference in New Issue
Block a user