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 {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||||
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
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>(()=>({
|
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;},
|
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),
|
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||||
}),[vehicleID]);
|
}),[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 [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [sensorOpen, setSensorOpen] = useState(false);
|
||||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||||
const lastCreateRequest = useRef(createRequest);
|
const lastCreateRequest = useRef(createRequest);
|
||||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [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>}
|
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||||
{!adding && error && <p role="alert">{error}</p>}
|
{!adding && error && <p role="alert">{error}</p>}
|
||||||
{detail ? <>
|
{detail ? <>
|
||||||
<div><Button onClick={() => setSelected(null)}>К списку аппаратов</Button></div>
|
<div><Button onClick={() => {setSelected(null);setSensorOpen(false);}}>К списку аппаратов</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>}>
|
{!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>}
|
{detail.notice && <p role="status">{detail.notice}</p>}
|
||||||
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
|
<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>
|
<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></>}
|
{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>
|
</dl>
|
||||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
||||||
</SettingsCard>
|
</SettingsCard>}
|
||||||
<SettingsCard title="Устройства аппарата"><VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></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>}
|
</> : !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>}>
|
<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}>
|
<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 { 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 > 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 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
|
name = n
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
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
|
var preparation any
|
||||||
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
|
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{}
|
operations := []any{}
|
||||||
for _, v := range s.operations {
|
for _, v := range s.operations {
|
||||||
if time.Now().Unix()-v.Updated < 600 {
|
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()
|
s.mu.Unlock()
|
||||||
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
|
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) {
|
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 {
|
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("Некорректная команда устройства.")
|
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("Операция не поддерживается.")
|
return nil, errors.New("Операция не поддерживается.")
|
||||||
}
|
}
|
||||||
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
|
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("Срок команды истёк. Устройство не изменено.")
|
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||||
}
|
}
|
||||||
for _, v := range s.operations {
|
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("Другая операция устройства ещё выполняется.")
|
return nil, errors.New("Другая операция устройства ещё выполняется.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,6 +273,7 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
|
|||||||
func (s *Sensors) execute(c SensorCommand) {
|
func (s *Sensors) execute(c SensorCommand) {
|
||||||
var result any
|
var result any
|
||||||
var err error
|
var err error
|
||||||
|
uncertain := false
|
||||||
inv := s.Inventory()
|
inv := s.Inventory()
|
||||||
var item map[string]any
|
var item map[string]any
|
||||||
for _, v := range inv["items"].([]any) {
|
for _, v := range inv["items"].([]any) {
|
||||||
@@ -293,6 +301,7 @@ func (s *Sensors) execute(c SensorCommand) {
|
|||||||
} else {
|
} else {
|
||||||
var v map[string]any
|
var v map[string]any
|
||||||
v, err = s.driver("/operation", c)
|
v, err = s.driver("/operation", c)
|
||||||
|
uncertain = err != nil || v["state"] == "unknown"
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if v["state"] == "complete" {
|
if v["state"] == "complete" {
|
||||||
result = v["result"]
|
result = v["result"]
|
||||||
@@ -308,6 +317,9 @@ func (s *Sensors) execute(c SensorCommand) {
|
|||||||
v.Updated = time.Now().Unix()
|
v.Updated = time.Now().Unix()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
v.State = "error"
|
v.State = "error"
|
||||||
|
if uncertain {
|
||||||
|
v.State = "unknown"
|
||||||
|
}
|
||||||
v.Error = err.Error()
|
v.Error = err.Error()
|
||||||
} else {
|
} else {
|
||||||
v.State = "complete"
|
v.State = "complete"
|
||||||
@@ -321,6 +333,13 @@ func (s *Sensors) execute(c SensorCommand) {
|
|||||||
func (s *Sensors) prepare(c SensorCommand) (any, error) {
|
func (s *Sensors) prepare(c SensorCommand) (any, error) {
|
||||||
s.prepareMu.Lock()
|
s.prepareMu.Lock()
|
||||||
defer s.prepareMu.Unlock()
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
|
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
|
||||||
|
|||||||
@@ -1,10 +1,86 @@
|
|||||||
package node
|
package node
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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 sensorTestCommand() SensorCommand {
|
||||||
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")}}
|
now := time.Now()
|
||||||
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")}}
|
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=="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=="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=="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
|
from build_deb import build, VERSION, BRAND_SHA256
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DG_COMMIT = "8a79dfe84d895c9f1d42b8d285bc6670114f939f"
|
DG_COMMIT = "17e150b1c74ab8a345fe34ce51dccd5bb862fa85"
|
||||||
|
|
||||||
|
|
||||||
def guideline_sources():
|
def guideline_sources():
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import tarfile
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.6.0"
|
VERSION = "0.6.6"
|
||||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ Description: Mission Core onboard computer configuration
|
|||||||
]:
|
]:
|
||||||
files.append((path, (p / source).read_bytes(), mode))
|
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))
|
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))
|
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"):
|
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))
|
files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644))
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ StateDirectoryMode=0700
|
|||||||
RuntimeDirectory=mission-core-sensors
|
RuntimeDirectory=mission-core-sensors
|
||||||
RuntimeDirectoryMode=0750
|
RuntimeDirectoryMode=0750
|
||||||
UMask=0077
|
UMask=0077
|
||||||
|
Environment=OPENBLAS_NUM_THREADS=1
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ case "$1" in
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable mission-core-node.service
|
systemctl enable mission-core-node.service
|
||||||
systemctl restart mission-core-node.service
|
systemctl restart mission-core-node.service
|
||||||
|
systemctl try-restart mission-core-realsense.service
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
set -eu
|
set -eu
|
||||||
if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||||
if [ -d /run/systemd/system ]; 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)
|
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
|
case "$mc_node_device_job" in
|
||||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
if [ -d /run/systemd/system ]; 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)
|
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
|
case "$mc_node_device_job" in
|
||||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
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."""
|
"""Fixed model job. No paths, packages, URLs or commands are accepted from clients."""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -25,11 +28,12 @@ STEPS = [
|
|||||||
|
|
||||||
def publish(value):
|
def publish(value):
|
||||||
ROOT.mkdir(mode=0o755, exist_ok=True)
|
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("Небезопасный каталог драйверов")
|
raise RuntimeError("Небезопасный каталог драйверов")
|
||||||
ROOT.chmod(0o755)
|
ROOT.chmod(0o755)
|
||||||
tmp = ROOT / ".preparation.tmp"
|
handle, name = tempfile.mkstemp(prefix=".preparation-", dir=ROOT)
|
||||||
with tmp.open("w") as f:
|
tmp = Path(name)
|
||||||
|
with os.fdopen(handle, "w") as f:
|
||||||
os.fchmod(f.fileno(), 0o644)
|
os.fchmod(f.fileno(), 0o644)
|
||||||
json.dump(value, f, ensure_ascii=False)
|
json.dump(value, f, ensure_ascii=False)
|
||||||
f.flush()
|
f.flush()
|
||||||
@@ -57,12 +61,75 @@ def safe_members(archive):
|
|||||||
yield info
|
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():
|
def prepare():
|
||||||
manifest = json.loads((SHARE / "bundle.json").read_text())
|
manifest = json.loads((SHARE / "bundle.json").read_text())
|
||||||
revision = manifest["revision"]
|
revision = manifest["revision"]
|
||||||
if not revision.isalnum():
|
if not revision.isalnum():
|
||||||
raise RuntimeError("Некорректная версия драйвера")
|
raise RuntimeError("Некорректная версия драйвера")
|
||||||
target = ROOT / revision
|
target = ROOT / revision
|
||||||
|
if target.is_symlink() or (ROOT / "active.path").is_symlink():
|
||||||
|
raise RuntimeError("Конфликт установленного драйвера")
|
||||||
|
imu_changed = False
|
||||||
state = {
|
state = {
|
||||||
"schema": "missioncore.node.device-preparation/v1",
|
"schema": "missioncore.node.device-preparation/v1",
|
||||||
"model_id": "realsense.d455",
|
"model_id": "realsense.d455",
|
||||||
@@ -129,10 +196,14 @@ def prepare():
|
|||||||
(ROOT / "active.path").write_text(str(target))
|
(ROOT / "active.path").write_text(str(target))
|
||||||
(ROOT / "active.path").chmod(0o644)
|
(ROOT / "active.path").chmod(0o644)
|
||||||
elif step["id"] == "access":
|
elif step["id"] == "access":
|
||||||
|
imu_changed = configure_imu_namespace()
|
||||||
source = SHARE / "70-mission-core-realsense.rules"
|
source = SHARE / "70-mission-core-realsense.rules"
|
||||||
dest = Path("/etc/udev/rules.d/70-mission-core-realsense.rules")
|
dest = Path("/etc/udev/rules.d/70-mission-core-realsense.rules")
|
||||||
if dest.is_symlink() or (
|
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(
|
raise RuntimeError(
|
||||||
"Правила доступа к камере изменены в системе. Чужая конфигурация сохранена."
|
"Правила доступа к камере изменены в системе. Чужая конфигурация сохранена."
|
||||||
@@ -156,11 +227,17 @@ def prepare():
|
|||||||
"--subsystem-match=video4linux",
|
"--subsystem-match=video4linux",
|
||||||
)
|
)
|
||||||
run("/usr/bin/udevadm", "trigger", "--action=change", "--subsystem-match=hidraw")
|
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")
|
run("/usr/bin/udevadm", "settle", "--timeout=10")
|
||||||
elif step["id"] == "service":
|
elif step["id"] == "service":
|
||||||
run("/usr/bin/systemctl", "enable", "mission-core-realsense.service")
|
run("/usr/bin/systemctl", "enable", "mission-core-realsense.service")
|
||||||
# Never restart a running acquisition on repeated preparation.
|
# The fixed job refuses running capture before changing its namespace.
|
||||||
run("/usr/bin/systemctl", "start", "mission-core-realsense.service")
|
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")
|
run("/usr/bin/systemctl", "is-active", "--quiet", "mission-core-realsense.service")
|
||||||
step["state"] = "complete"
|
step["state"] = "complete"
|
||||||
publish(state)
|
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 hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -18,7 +20,7 @@ from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
|
|||||||
|
|
||||||
MODEL = {
|
MODEL = {
|
||||||
"plugin_id": "missioncore.realsense",
|
"plugin_id": "missioncore.realsense",
|
||||||
"plugin_version": "0.6.0",
|
"plugin_version": "0.6.6",
|
||||||
"model_id": "realsense.d455",
|
"model_id": "realsense.d455",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +46,19 @@ def kind(profile):
|
|||||||
return str(profile.stream_type()).split(".")[-1]
|
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:
|
class Device:
|
||||||
def __init__(self, serial, root, execution):
|
def __init__(self, serial, root, execution, usb_serial=None):
|
||||||
self.serial = serial
|
self.serial = serial
|
||||||
self.id = device_id(serial)
|
self.id = device_id(usb_serial or serial)
|
||||||
self.root = root / self.id
|
self.root = root / self.id
|
||||||
self.root.mkdir(exist_ok=True, mode=0o700)
|
self.root.mkdir(exist_ok=True, mode=0o700)
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
@@ -69,6 +80,7 @@ class Device:
|
|||||||
self.frames = {}
|
self.frames = {}
|
||||||
self.last_frame = None
|
self.last_frame = None
|
||||||
self.record = None
|
self.record = None
|
||||||
|
self.playback_id = None
|
||||||
self.profiles = []
|
self.profiles = []
|
||||||
self.options = []
|
self.options = []
|
||||||
self.sdk_device = None
|
self.sdk_device = None
|
||||||
@@ -91,6 +103,10 @@ class Device:
|
|||||||
def refresh(self, dev):
|
def refresh(self, dev):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.sdk_device = dev
|
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.online = True
|
||||||
self.firmware = dev.get_info(rs.camera_info.firmware_version)
|
self.firmware = dev.get_info(rs.camera_info.firmware_version)
|
||||||
self.transport = dev.get_info(rs.camera_info.usb_type_descriptor)
|
self.transport = dev.get_info(rs.camera_info.usb_type_descriptor)
|
||||||
@@ -204,6 +220,7 @@ class Device:
|
|||||||
"frames": dict(self.frames),
|
"frames": dict(self.frames),
|
||||||
"last_frame": self.last_frame,
|
"last_frame": self.last_frame,
|
||||||
"recording": self.record,
|
"recording": self.record,
|
||||||
|
"playback_id": self.playback_id,
|
||||||
"layers": list(self.images)
|
"layers": list(self.images)
|
||||||
+ (["points"] if self.depth is not None else [])
|
+ (["points"] if self.depth is not None else [])
|
||||||
+ (["motion"] if self.motion else []),
|
+ (["motion"] if self.motion else []),
|
||||||
@@ -253,15 +270,11 @@ class Device:
|
|||||||
raise ValueError("Выберите хотя бы один видеопоток.")
|
raise ValueError("Выберите хотя бы один видеопоток.")
|
||||||
config = rs.config()
|
config = rs.config()
|
||||||
config.enable_device(self.serial)
|
config.enable_device(self.serial)
|
||||||
for p in profiles:
|
configure_profiles(config, 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"])
|
|
||||||
pipeline = rs.pipeline()
|
pipeline = rs.pipeline()
|
||||||
if not config.can_resolve(rs.pipeline_wrapper(pipeline)):
|
if not config.can_resolve(rs.pipeline_wrapper(pipeline)):
|
||||||
raise ValueError("Камера не поддерживает эту комбинацию профилей. Выберите другую.")
|
raise ValueError("Камера не поддерживает эту комбинацию профилей. Выберите другую.")
|
||||||
|
self.playback_id = None
|
||||||
self.acquisition = "starting"
|
self.acquisition = "starting"
|
||||||
self.revision += 1
|
self.revision += 1
|
||||||
self.images, self.motion, self.frames = {}, {}, {}
|
self.images, self.motion, self.frames = {}, {}, {}
|
||||||
@@ -276,7 +289,7 @@ class Device:
|
|||||||
ident = "capture_" + uuid.uuid4().hex
|
ident = "capture_" + uuid.uuid4().hex
|
||||||
record_path = self.root / "recordings" / ident
|
record_path = self.root / "recordings" / ident
|
||||||
record_path.mkdir(parents=True, mode=0o700)
|
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 = {
|
self.record = {
|
||||||
"id": ident,
|
"id": ident,
|
||||||
"state": "recording",
|
"state": "recording",
|
||||||
@@ -287,6 +300,7 @@ class Device:
|
|||||||
"profiles": profiles,
|
"profiles": profiles,
|
||||||
"firmware": self.firmware,
|
"firmware": self.firmware,
|
||||||
"sdk": "2.58.4.10922",
|
"sdk": "2.58.4.10922",
|
||||||
|
"storage_format": "rosbag2-sqlite3",
|
||||||
"options": self.options,
|
"options": self.options,
|
||||||
}
|
}
|
||||||
atomic(record_path / "manifest.json", self.record)
|
atomic(record_path / "manifest.json", self.record)
|
||||||
@@ -320,7 +334,8 @@ class Device:
|
|||||||
atomic(record_path / "manifest.json", self.record)
|
atomic(record_path / "manifest.json", self.record)
|
||||||
self.thread = threading.Thread(target=self.consume, daemon=True)
|
self.thread = threading.Thread(target=self.consume, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
except Exception:
|
except Exception as error:
|
||||||
|
logging.error("D455 capture: %s", str(error).replace(self.serial, "[camera]"))
|
||||||
with suppress(RuntimeError):
|
with suppress(RuntimeError):
|
||||||
pipeline.stop()
|
pipeline.stop()
|
||||||
self.pipeline = None
|
self.pipeline = None
|
||||||
@@ -332,6 +347,49 @@ class Device:
|
|||||||
self.record = None
|
self.record = None
|
||||||
raise ValueError(self.message) from 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):
|
def consume(self):
|
||||||
colorizer = rs.colorizer()
|
colorizer = rs.colorizer()
|
||||||
last_data, last_disk = time.monotonic(), time.monotonic()
|
last_data, last_disk = time.monotonic(), time.monotonic()
|
||||||
@@ -387,8 +445,10 @@ class Device:
|
|||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
self.acquisition = "stopping"
|
self.acquisition = "stopping"
|
||||||
pipeline.stop()
|
pipeline.stop()
|
||||||
|
del pipeline
|
||||||
if self.thread and not from_capture:
|
if self.thread and not from_capture:
|
||||||
self.thread.join(timeout=3)
|
self.thread.join(timeout=3)
|
||||||
|
self.playback_id = None
|
||||||
self.acquisition = "failed" if failed else "idle"
|
self.acquisition = "failed" if failed else "idle"
|
||||||
self.revision += 1
|
self.revision += 1
|
||||||
if self.record:
|
if self.record:
|
||||||
@@ -400,7 +460,7 @@ class Device:
|
|||||||
frames=dict(self.frames),
|
frames=dict(self.frames),
|
||||||
)
|
)
|
||||||
path = self.root / "recordings" / value["id"]
|
path = self.root / "recordings" / value["id"]
|
||||||
source = path / "source.bag"
|
source = path / "source.db3"
|
||||||
if source.exists():
|
if source.exists():
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
with source.open("rb") as f:
|
with source.open("rb") as f:
|
||||||
@@ -465,6 +525,8 @@ class Device:
|
|||||||
or not item["min"] <= value <= item["max"]
|
or not item["min"] <= value <= item["max"]
|
||||||
):
|
):
|
||||||
raise ValueError("Параметр недоступен или значение вне диапазона.")
|
raise ValueError("Параметр недоступен или значение вне диапазона.")
|
||||||
|
if self.playback_id:
|
||||||
|
raise ValueError("Остановите просмотр записи перед настройкой камеры.")
|
||||||
sensor_index, option_id = map(int, identifier.split(":"))
|
sensor_index, option_id = map(int, identifier.split(":"))
|
||||||
sensor = self.sdk_device.query_sensors()[sensor_index]
|
sensor = self.sdk_device.query_sensors()[sensor_index]
|
||||||
option = rs.option(option_id)
|
option = rs.option(option_id)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ipaddress
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from fractions import Fraction
|
||||||
|
|
||||||
import aioice.ice
|
import aioice.ice
|
||||||
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
|
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
|
||||||
@@ -35,11 +36,16 @@ class CameraTrack(VideoStreamTrack):
|
|||||||
def __init__(self, device, layer):
|
def __init__(self, device, layer):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.device, self.layer = device, layer
|
self.device, self.layer = device, layer
|
||||||
|
self.started = None
|
||||||
|
self.sequence = 0
|
||||||
|
|
||||||
async def recv(self):
|
async def recv(self):
|
||||||
pts, base = await self.next_timestamp()
|
# Fixed 15 Hz preview clock; raw hardware timing is independent.
|
||||||
# Bound preview to 15 Hz; hardware profiles and raw recording are independent.
|
if self.started is None:
|
||||||
await asyncio.sleep(1 / 30)
|
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:
|
while self.layer not in self.device.images:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
frame = VideoFrame.from_ndarray(self.device.images[self.layer], format="rgb24")
|
frame = VideoFrame.from_ndarray(self.device.images[self.layer], format="rgb24")
|
||||||
|
|||||||
@@ -37,17 +37,35 @@ class Host:
|
|||||||
def work():
|
def work():
|
||||||
found = set()
|
found = set()
|
||||||
for dev in self.context.query_devices():
|
for dev in self.context.query_devices():
|
||||||
|
if dev.is_playback():
|
||||||
|
continue
|
||||||
if dev.get_info(rs.camera_info.product_id).lower() != "0b5c":
|
if dev.get_info(rs.camera_info.product_id).lower() != "0b5c":
|
||||||
continue
|
continue
|
||||||
serial = dev.get_info(rs.camera_info.serial_number)
|
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)
|
found.add(ident)
|
||||||
if ident not in self.devices:
|
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)
|
self.devices[ident].refresh(dev)
|
||||||
for ident, device in self.devices.items():
|
for ident, device in self.devices.items():
|
||||||
if ident not in found:
|
if ident not in found:
|
||||||
device.online = False
|
device.online = False
|
||||||
|
device.verified_this_process = False
|
||||||
|
|
||||||
await asyncio.to_thread(work)
|
await asyncio.to_thread(work)
|
||||||
|
|
||||||
@@ -112,6 +130,8 @@ class Host:
|
|||||||
device.start, params.get("profiles"), params.get("record", False)
|
device.start, params.get("profiles"), params.get("record", False)
|
||||||
)
|
)
|
||||||
result = {"ok": True}
|
result = {"ok": True}
|
||||||
|
elif action == "replay":
|
||||||
|
result = await asyncio.to_thread(device.replay, params.get("recording_id"))
|
||||||
elif action == "stop":
|
elif action == "stop":
|
||||||
result = await asyncio.to_thread(device.stop)
|
result = await asyncio.to_thread(device.stop)
|
||||||
elif action == "option":
|
elif action == "option":
|
||||||
@@ -128,8 +148,20 @@ class Host:
|
|||||||
except (ValueError, RuntimeError) as error:
|
except (ValueError, RuntimeError) as error:
|
||||||
receipt["result"] = {"state": "error", "error": str(error)[:400]}
|
receipt["result"] = {"state": "error", "error": str(error)[:400]}
|
||||||
atomic(path, receipt)
|
atomic(path, receipt)
|
||||||
|
self.operation_locks.pop(identifier, None)
|
||||||
return web.json_response(receipt["result"])
|
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):
|
async def cleanup(self, app):
|
||||||
for peer in list(self.peers.items):
|
for peer in list(self.peers.items):
|
||||||
await self.peers.close(peer)
|
await self.peers.close(peer)
|
||||||
@@ -154,6 +186,7 @@ def main():
|
|||||||
host = Host()
|
host = Host()
|
||||||
app = web.Application(client_max_size=65536, middlewares=[errors])
|
app = web.Application(client_max_size=65536, middlewares=[errors])
|
||||||
app.router.add_get("/inventory", host.inventory)
|
app.router.add_get("/inventory", host.inventory)
|
||||||
|
app.router.add_get("/prepare-safe", host.prepare_safe)
|
||||||
app.router.add_post("/operation", host.operation)
|
app.router.add_post("/operation", host.operation)
|
||||||
app.on_cleanup.append(host.cleanup)
|
app.on_cleanup.append(host.cleanup)
|
||||||
if SOCKET.exists():
|
if SOCKET.exists():
|
||||||
|
|||||||
@@ -77,3 +77,38 @@ Node/Core, переименование, глазик, профили, дейс
|
|||||||
но не заменяют её. Реальные эксперименты имеют приватный manifest с UTC,
|
но не заменяют её. Реальные эксперименты имеют приватный manifest с UTC,
|
||||||
monotonic, границами и SHA-256. Чистая установка остаётся отложенным владельцем
|
monotonic, границами и SHA-256. Чистая установка остаётся отложенным владельцем
|
||||||
критерием; текущий подготовленный Mini не выдаётся за чистую систему.
|
критерием; текущий подготовленный 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(()=>{
|
useEffect(()=>{
|
||||||
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||||
let cancelled=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();
|
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});
|
const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
|
||||||
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
|
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
|
||||||
pc.ontrack=event=>{if(video.current)video.current.srcObject=new MediaStream([event.track]);};
|
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. */ }};
|
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);
|
timer=setInterval(()=>{if(channel.readyState==='open')channel.send('keepalive');if(Date.now()-last>8000)setState('Нет свежих кадров');},2000);
|
||||||
async function connect(){try{
|
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});
|
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
|
||||||
}catch(e){if(!cancelled){setState('Просмотр недоступен');failure(e);}}}
|
}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);};
|
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);},[]);
|
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';
|
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>:<>
|
{!active?<p>Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.</p>:<>
|
||||||
{state==='Подключаем просмотр'&&<ActivityIndicator label="Открываем частный канал камеры"/>}
|
{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/>}
|
{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 {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 {perform,type Sensor,type SensorTransport} from './contracts';
|
||||||
import {LiveViewport} from './LiveViewport';
|
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}){
|
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('');
|
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;}
|
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]);
|
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);}}
|
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 groups=[...new Set((detail?.profiles??[]).map(p=>p.stream+':'+p.index))];
|
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 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';
|
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>
|
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:''}`}>
|
<SettingsCard title={device.name} description={`${device.model} · USB ${device.usb}${device.firmware?' · '+device.firmware:''}`}>
|
||||||
{device.snapshot.message&&<p>{device.snapshot.message}</p>}
|
{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>}
|
{device.recording&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
||||||
</SettingsCard>
|
</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}/>
|
{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="Профили потоков" 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="Параметры камеры"><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></div>):<p>Записей пока нет.</p>}</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>}
|
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,22 @@ import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,Settin
|
|||||||
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||||
import {SensorDetail} from './SensorDetail';
|
import {SensorDetail} from './SensorDetail';
|
||||||
import './sensors.css';
|
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 [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]);
|
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]);
|
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);
|
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}/>:<>
|
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>
|
<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=>{
|
{!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 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?'Драйвер установлен':'Нужно подготовить';
|
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
||||||
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>}
|
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':item.playback_id?'Просмотр записи':'Идёт захват':item.verified?'Проверено':item.prepared?'Драйвер установлен':'Требуется подготовка';
|
||||||
{(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>}
|
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>
|
<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('')}/>
|
<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 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 SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
||||||
export interface Sensor {
|
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};
|
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[];
|
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
||||||
frames?: Record<string,number>; last_frame?: {observed_at:string}; recording?: {id: 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}[];
|
recordings?: {id:string;state:string;started_at:string;bytes?:number;sha256?:string}[];
|
||||||
}
|
}
|
||||||
export interface SensorInventory {
|
export interface SensorInventory {
|
||||||
items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;state:string;error?:string}[];
|
items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;requested_at:string;state:string;error?:string}[];
|
||||||
preparation?: {state:string;steps:{id:string;label:string;state:string;message?:string}[]};
|
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
|
||||||
}
|
}
|
||||||
export interface SensorCommand {
|
export interface SensorCommand {
|
||||||
api_version: 'missioncore.nodedc/plugin-sdk/v0alpha2'; kind:'OperationRequest'; operation_id:string;
|
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 {display:grid;gap:var(--nodedc-space-4);font-size:var(--nodedc-font-size-sm)}
|
||||||
.sensor-facts dd {margin:0}
|
.sensor-facts dd {margin:0}
|
||||||
.sensor-media {display:block;width:100%;height:360px;object-fit:contain;touch-action:none}
|
.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-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)}
|
.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",
|
"rename",
|
||||||
"verify",
|
"verify",
|
||||||
"start",
|
"start",
|
||||||
|
"replay",
|
||||||
"stop",
|
"stop",
|
||||||
"option",
|
"option",
|
||||||
"offer",
|
"offer",
|
||||||
|
|||||||
Reference in New Issue
Block a user