Add D455 sensor host and shared Node/Core preparation surface

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 22:27:07 +03:00
parent 3616acc648
commit b1aaa40508
41 changed files with 2293 additions and 19 deletions
@@ -0,0 +1,12 @@
import {useMemo} from 'react';
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
import {fleetRequest} from '../../core/fleet/useFleet';
export function VehicleSensors({vehicleID,enabled}:{vehicleID:string;enabled:boolean}){
const transport=useMemo<SensorTransport>(()=>({
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return value.sensor_state;},
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
}),[vehicleID]);
return <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled}/>;
}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
import "./fleet.css";
import { VehicleSensors } from "./VehicleSensors";
const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }];
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
@@ -51,7 +52,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
</dl>
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
</SettingsCard>
<SettingsCard title="Устройства аппарата"><p>Устройства к аппарату ещё не подключены.</p></SettingsCard>
<SettingsCard title="Устройства аппарата"><VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
+6
View File
@@ -74,6 +74,12 @@ func run() error {
return err
}
app.Pairing = pairing
nodeID, _ := store.Public()
app.Sensors, err = node.OpenSensors(*dir, nodeID)
if err != nil {
return err
}
pairing.Sensors = app.Sensors
app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }}
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
return err
+1
View File
@@ -39,6 +39,7 @@ type PairState struct {
Revocations []CoreBinding `json:"revocations,omitempty"`
}
type Pairing struct {
Sensors *Sensors
mu sync.Mutex
path string
store *Store
@@ -231,7 +231,7 @@ func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload
}
defer response.Body.Close()
var out map[string]json.RawMessage
if json.NewDecoder(io.LimitReader(response.Body, 16384)).Decode(&out) != nil {
if json.NewDecoder(io.LimitReader(response.Body, 1048576)).Decode(&out) != nil {
return nil, response.StatusCode, errors.New("invalid Core response")
}
return out, response.StatusCode, nil
@@ -270,12 +270,30 @@ func (p *Pairing) channel(ctx context.Context) {
if binding != nil {
id, name := p.store.Public()
payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}}
if p.Sensors != nil {
inv := p.Sensors.Inventory()
payload["devices"] = inv["items"]
payload["sensor_state"] = inv
payload["sensor_results"] = p.Sensors.RemoteResults()
}
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
p.mu.Lock()
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
if e == nil && status == 200 {
p.connection = "online"
p.lastSeen = p.now().Unix()
if p.Sensors != nil {
var ack []string
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
p.Sensors.Acknowledge(ack)
}
var commands []SensorCommand
if json.Unmarshal(result["sensor_commands"], &commands) == nil {
for _, c := range commands {
_, _ = p.Sensors.Submit(c, true)
}
}
}
var cert string
if json.Unmarshal(result["client_pem"], &cert) == nil && cert != "" && cert != binding.ClientPEM {
next := *binding
+422
View File
@@ -0,0 +1,422 @@
package node
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
const SensorSchema = "missioncore.nodedc/plugin-sdk/v0alpha2"
type SensorSession struct {
SessionID string `json:"session_id"`
DeviceID string `json:"device_id"`
}
type SensorCommand struct {
APIVersion string `json:"api_version"`
Kind string `json:"kind"`
ID string `json:"operation_id"`
Session SensorSession `json:"session"`
Action string `json:"action_id"`
Requested string `json:"requested_at"`
Deadline string `json:"deadline_at"`
Idempotency string `json:"idempotency_key"`
Parameters map[string]any `json:"parameters"`
}
type SensorOperation struct {
Command SensorCommand `json:"command"`
State string `json:"state"`
Error string `json:"error,omitempty"`
Result any `json:"result,omitempty"`
Remote bool `json:"remote,omitempty"`
Updated int64 `json:"updated_at"`
}
type Sensors struct {
mu sync.Mutex
prepareMu sync.Mutex
root string
nodeID string
instance string
client *http.Client
operations map[string]*SensorOperation
names map[string]string
}
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
func OpenSensors(root, nodeID string) (*Sensors, error) {
dir := filepath.Join(root, "sensors")
if e := os.MkdirAll(dir, 0700); e != nil {
return nil, e
}
s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}}
s.client = &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-sensors/driver.sock")
}}}
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
for _, p := range files {
data, e := os.ReadFile(p)
if e != nil {
return nil, e
}
var v SensorOperation
if json.Unmarshal(data, &v) != nil {
return nil, errors.New("invalid sensor operation journal")
}
if v.State == "running" {
v.State = "unknown"
v.Error = "Результат операции неизвестен после перезапуска. Проверьте состояние устройства."
}
s.operations[v.Command.ID] = &v
}
data, _ := os.ReadFile(filepath.Join(dir, "names.json"))
_ = json.Unmarshal(data, &s.names)
return s, nil
}
func (s *Sensors) write(name string, value any) error {
data, e := json.Marshal(value)
if e != nil {
return e
}
f, e := os.CreateTemp(s.root, ".sensor-")
if e != nil {
return e
}
defer os.Remove(f.Name())
if _, e = f.Write(data); e != nil {
f.Close()
return e
}
if e = f.Sync(); e != nil {
f.Close()
return e
}
f.Close()
if e = os.Rename(f.Name(), filepath.Join(s.root, name)); e != nil {
return e
}
d, e := os.Open(s.root)
if e != nil {
return e
}
defer d.Close()
return d.Sync()
}
func (s *Sensors) driver(path string, body any) (map[string]any, error) {
method := "GET"
var reader io.Reader
if body != nil {
method = "POST"
data, e := json.Marshal(body)
if e != nil {
return nil, e
}
reader = bytes.NewReader(data)
}
req, e := http.NewRequest(method, "http://driver"+path, reader)
if e != nil {
return nil, e
}
req.Header.Set("X-Node-Id", s.nodeID)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
response, e := s.client.Do(req)
if e != nil {
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
}
defer response.Body.Close()
var result map[string]any
if json.NewDecoder(io.LimitReader(response.Body, 1024*1024)).Decode(&result) != nil {
return nil, errors.New("Не удалось прочитать результат драйвера.")
}
if response.StatusCode != 200 {
message, _ := result["error"].(string)
return nil, errors.New(message)
}
return result, nil
}
func (s *Sensors) Inventory() map[string]any {
items := []any{}
seen := map[string]bool{}
if result, e := s.driver("/inventory", nil); e == nil {
if found, ok := result["items"].([]any); ok {
for _, v := range found {
item, ok := v.(map[string]any)
if !ok {
continue
}
id, _ := item["id"].(string)
seen[id] = true
s.mu.Lock()
if n := s.names[id]; n != "" {
item["name"] = n
}
s.mu.Unlock()
items = append(items, item)
}
}
}
paths, _ := filepath.Glob("/sys/bus/usb/devices/*")
for _, path := range paths {
read := func(n string) string {
b, _ := os.ReadFile(filepath.Join(path, n))
return strings.TrimSpace(string(b))
}
if read("idVendor") != "8086" || read("idProduct") != "0b5c" {
continue
}
serial := read("serial")
if serial == "" {
continue
}
h := sha256.Sum256([]byte(serial))
id := "rsd455_" + hex.EncodeToString(h[:])[:32]
if seen[id] {
continue
}
now := time.Now().UTC().Format(time.RFC3339Nano)
name := "RealSense D455"
s.mu.Lock()
if n := s.names[id]; n != "" {
name = n
}
s.mu.Unlock()
items = append(items, map[string]any{"id": id, "name": name, "model": "RealSense D455", "prepared": false, "verified": false, "online": true, "usb": read("speed") + " Мбит/с", "layers": []any{}, "snapshot": map[string]any{"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.0", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now}, "revision": 0, "enrollment": "empty", "connectivity": "connected", "acquisition": "idle", "observed_at": now}})
}
var preparation any
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
_ = json.Unmarshal(data, &preparation)
}
s.mu.Lock()
operations := []any{}
for _, v := range s.operations {
if time.Now().Unix()-v.Updated < 600 {
operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "state": v.State, "error": v.Error})
}
}
s.mu.Unlock()
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
}
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
return nil, errors.New("Некорректная команда устройства.")
}
if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] {
return nil, errors.New("Операция не поддерживается.")
}
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
requested, e2 := time.Parse(time.RFC3339Nano, c.Requested)
if e != nil || e2 != nil || !deadline.After(requested) || deadline.Sub(requested) > 6*time.Minute {
return nil, errors.New("Некорректный срок команды.")
}
s.mu.Lock()
defer s.mu.Unlock()
if old := s.operations[c.ID]; old != nil {
a, _ := json.Marshal(old.Command)
b, _ := json.Marshal(c)
if !bytes.Equal(a, b) {
return nil, errors.New("Идентификатор операции уже использован.")
}
copy := *old
return &copy, nil
}
if !deadline.After(time.Now()) {
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
}
for _, v := range s.operations {
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID {
return nil, errors.New("Другая операция устройства ещё выполняется.")
}
}
if len(s.operations) > 2000 {
for id, v := range s.operations {
if v.State != "running" && time.Now().Unix()-v.Updated > 86400 {
delete(s.operations, id)
os.Remove(filepath.Join(s.root, id+".json"))
}
}
}
if len(s.operations) > 2000 {
return nil, errors.New("Журнал операций заполнен. Повторите позже.")
}
value := &SensorOperation{Command: c, State: "running", Remote: remote, Updated: time.Now().Unix()}
if e = s.write(c.ID+".json", value); e != nil {
return nil, e
}
s.operations[c.ID] = value
copy := *value
go s.execute(c)
return &copy, nil
}
func (s *Sensors) execute(c SensorCommand) {
var result any
var err error
inv := s.Inventory()
var item map[string]any
for _, v := range inv["items"].([]any) {
i := v.(map[string]any)
if i["id"] == c.Session.DeviceID {
item = i
break
}
}
if item == nil {
err = errors.New("Камера не обнаружена. Проверьте подключение.")
} else if c.Action == "prepare" {
result, err = s.prepare(c)
} else if c.Action == "rename" {
name, ok := c.Parameters["name"].(string)
if !ok || strings.TrimSpace(name) == "" || len([]rune(name)) > 80 || strings.ContainsAny(name, "\n\r\t") {
err = errors.New("Введите название до 80 символов.")
} else {
s.mu.Lock()
s.names[c.Session.DeviceID] = strings.TrimSpace(name)
err = s.write("names.json", s.names)
s.mu.Unlock()
result = map[string]bool{"ok": err == nil}
}
} else {
var v map[string]any
v, err = s.driver("/operation", c)
if err == nil {
if v["state"] == "complete" {
result = v["result"]
} else {
message, _ := v["error"].(string)
err = errors.New(message)
}
}
}
s.mu.Lock()
defer s.mu.Unlock()
v := s.operations[c.ID]
v.Updated = time.Now().Unix()
if err != nil {
v.State = "error"
v.Error = err.Error()
} else {
v.State = "complete"
v.Result = result
}
if s.write(c.ID+".json", v) != nil {
v.State = "unknown"
v.Error = "Не удалось сохранить результат операции. Обновите состояние устройства."
}
}
func (s *Sensors) prepare(c SensorCommand) (any, error) {
s.prepareMu.Lock()
defer s.prepareMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"}
if cmd.Run() != nil {
return nil, errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.")
}
for i := 0; i < 12; i++ {
inv := s.Inventory()
for _, v := range inv["items"].([]any) {
item := v.(map[string]any)
if item["id"] == c.Session.DeviceID && item["prepared"] == true {
snap := item["snapshot"].(map[string]any)
sc := snap["context"].(map[string]any)
verify := c
verify.Action = "verify"
verify.Session.SessionID = sc["session_id"].(string)
result, e := s.driver("/operation", verify)
if e != nil {
return nil, e
}
if result["state"] != "complete" {
message, _ := result["error"].(string)
return nil, errors.New(message)
}
return result["result"], nil
}
}
time.Sleep(time.Second)
}
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
}
func (s *Sensors) Get(id string) *SensorOperation {
s.mu.Lock()
defer s.mu.Unlock()
if v := s.operations[id]; v != nil {
copy := *v
return &copy
}
return nil
}
func (s *Sensors) RemoteResults() []any {
s.mu.Lock()
defer s.mu.Unlock()
out := []any{}
for _, v := range s.operations {
if v.Remote && time.Now().Unix()-v.Updated < 600 {
copy := *v
out = append(out, copy)
}
}
return out
}
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
if server.authorized(w, r) {
reply(w, 200, s.Inventory())
}
})
mux.HandleFunc("POST /api/devices/operations", func(w http.ResponseWriter, r *http.Request) {
if !server.authorized(w, r) {
return
}
var c SensorCommand
r.Body = http.MaxBytesReader(w, r.Body, 65536)
if r.Header.Get("Content-Type") != "application/json" || json.NewDecoder(r.Body).Decode(&c) != nil {
reply(w, 400, map[string]string{"error": "Некорректная команда"})
return
}
v, e := s.Submit(c, false)
if e != nil {
reply(w, 409, map[string]string{"error": e.Error()})
return
}
reply(w, 202, v)
})
mux.HandleFunc("GET /api/devices/operations/{id}", func(w http.ResponseWriter, r *http.Request) {
if !server.authorized(w, r) {
return
}
v := s.Get(r.PathValue("id"))
if v == nil {
reply(w, 404, map[string]string{"error": "Операция не найдена"})
return
}
reply(w, 200, v)
})
}
func (s *Sensors) Acknowledge(ids []string) {
s.mu.Lock()
defer s.mu.Unlock()
for _, id := range ids {
if v := s.operations[id]; v != nil && v.State != "running" {
v.Remote = false
_ = s.write(id+".json", v)
}
}
}
@@ -0,0 +1,10 @@
package node
import (
"testing"
"time"
)
func sensorTestCommand() SensorCommand {now:=time.Now();id:="op_01234567890123456789012345678901";return SensorCommand{APIVersion:SensorSchema,Kind:"OperationRequest",ID:id,Idempotency:id,Session:SensorSession{SessionID:"session_test",DeviceID:"rsd455_01234567890123456789012345678901"},Action:"start",Requested:now.UTC().Format(time.RFC3339Nano),Deadline:now.Add(time.Minute).UTC().Format(time.RFC3339Nano),Parameters:map[string]any{}}}
func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T){s,e:=OpenSensors(t.TempDir(),"node_test");if e!=nil{t.Fatal(e)};c:=sensorTestCommand();c.Action="shell";if _,e=s.Submit(c,false);e==nil{t.Fatal("arbitrary action admitted")};c=sensorTestCommand();c.ID="../../owned";if _,e=s.Submit(c,false);e==nil{t.Fatal("path admitted")};c=sensorTestCommand();c.Requested=time.Now().Add(-2*time.Minute).Format(time.RFC3339Nano);c.Deadline=time.Now().Add(-time.Minute).Format(time.RFC3339Nano);if _,e=s.Submit(c,false);e==nil{t.Fatal("expired command admitted")}}
func TestSensorUncertainCrashDoesNotReplay(t *testing.T){root:=t.TempDir();s,_:=OpenSensors(root,"node_test");c:=sensorTestCommand();old:=&SensorOperation{Command:c,State:"running",Updated:time.Now().Unix()};if e:=s.write(c.ID+".json",old);e!=nil{t.Fatal(e)};s,e:=OpenSensors(root,"node_test");if e!=nil{t.Fatal(e)};v,e:=s.Submit(c,false);if e!=nil||v.State!="unknown"{t.Fatalf("replay: %+v %v",v,e)};c.Parameters=map[string]any{"record":true};if _,e=s.Submit(c,false);e==nil{t.Fatal("id collision did not reject different command")}}
+4
View File
@@ -15,6 +15,7 @@ import (
type Server struct {
Store *Store
Pairing *Pairing
Sensors *Sensors
Assets fs.FS
Origin string
Version string
@@ -78,6 +79,9 @@ func reply(w http.ResponseWriter, status int, v any) {
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
if s.Sensors != nil {
s.Sensors.Routes(mux, s)
}
if s.Pairing != nil {
s.Pairing.localRoutes(mux, s)
}
@@ -0,0 +1,6 @@
// An authenticated Node action may start only this fixed model job.
polkit.addRule(function(action, subject) {
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && action.lookup("unit") === "mission-core-node-realsense-prepare.service" && action.lookup("verb") === "start") {
return polkit.Result.YES;
}
});
@@ -0,0 +1,4 @@
# Reviewed D455 only. No firmware/DFU IDs, unrelated cameras or world-writable devices.
SUBSYSTEM=="usb", ATTR{idVendor}=="8086", ATTR{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="video4linux", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors"
+20 -2
View File
@@ -13,7 +13,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.5.1"
VERSION = "0.6.0"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
@@ -33,7 +33,7 @@ def desktop_icon(brand):
def tarball(files):
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w", format=tarfile.USTAR_FORMAT) as archive:
with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive:
directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."}
for name in sorted(directories):
item = tarfile.TarInfo(name + "/")
@@ -92,6 +92,24 @@ Description: Mission Core onboard computer configuration
]:
files.append((path, (p / source).read_bytes(), mode))
files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644))
for name in ("realsense_prepare.py",):
files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644))
for name in ("mission-core-realsense.service", "mission-core-node-realsense-prepare.service"):
files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644))
files.append(("usr/share/polkit-1/rules.d/50-mission-core-device-prepare.rules", (p / "50-mission-core-device-prepare.rules").read_bytes(), 0o644))
files.append(("usr/share/mission-core-node/realsense/70-mission-core-realsense.rules", (p / "70-mission-core-realsense.rules").read_bytes(), 0o644))
bundle = json.loads((p / "realsense-bundle.json").read_text())
files.append(("usr/share/mission-core-node/realsense/bundle.json", (p / "realsense-bundle.json").read_bytes(), 0o644))
for item in bundle["wheels"]:
data = (ROOT / "build/realsense-wheels" / item["name"]).read_bytes()
if hashlib.sha256(data).hexdigest() != item["sha256"]:
raise ValueError("Driver bundle hash mismatch")
files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644))
for path in (ROOT / "sensors").glob("*.py"):
files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644))
sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk"
for path in sdk.rglob("*.py"):
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
if (ROOT / "build/provenance.json").exists():
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
archive = b"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
@@ -0,0 +1,30 @@
"""Engineering build input, never run on an operator board. Exact PyPI hashes only."""
import hashlib
import json
from pathlib import Path
from urllib.request import urlopen
root = Path(__file__).resolve().parents[1]
manifest = json.loads((root / "packaging/realsense-bundle.json").read_text())
output = root / "build/realsense-wheels"
output.mkdir(parents=True, exist_ok=True)
for item in manifest["wheels"]:
target = output / item["name"]
if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() == item["sha256"]:
continue
package, version = item["name"].split("-")[:2]
with urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=30) as response:
metadata = json.load(response)
source = next(
v
for v in metadata["urls"]
if v["filename"] == item["name"] and v["digests"]["sha256"] == item["sha256"]
)
if not source["url"].startswith("https://files.pythonhosted.org/"):
raise ValueError("Unexpected package origin")
with urlopen(source["url"], timeout=120) as response:
data = response.read(item["bytes"] + 1)
if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]:
raise ValueError("Driver checksum mismatch")
target.write_bytes(data)
@@ -0,0 +1,8 @@
[Unit]
Description=Mission Core fixed RealSense model preparation
After=systemd-udevd.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/realsense_prepare.py
TimeoutStartSec=300
UMask=0022
@@ -0,0 +1,31 @@
[Unit]
Description=Mission Core isolated RealSense driver
After=network.target
ConditionPathExists=/var/lib/mission-core-node-drivers/active.path
[Service]
User=mission-core-sensors
Group=mission-core-node
SupplementaryGroups=mission-core-sensors
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/sensors/bootstrap.py
StateDirectory=mission-core-sensors
StateDirectoryMode=0700
RuntimeDirectory=mission-core-sensors
RuntimeDirectoryMode=0750
UMask=0077
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_NETLINK
CapabilityBoundingSet=
LockPersonality=yes
TasksMax=128
MemoryMax=900M
[Install]
WantedBy=multi-user.target
+3
View File
@@ -5,6 +5,9 @@ case "$1" in
if ! getent passwd mission-core-node >/dev/null; then
adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
fi
if ! getent passwd mission-core-sensors >/dev/null; then
adduser --system --group --home /var/lib/mission-core-sensors --no-create-home --disabled-login mission-core-sensors
fi
# Only bootstrap required to open the GUI. Operational configuration is a
# versioned job started by «Настройка окружения → Сконфигурировать».
if [ -d /run/systemd/system ]; then
+5
View File
@@ -2,6 +2,11 @@
set -eu
if [ "$1" = install ] || [ "$1" = upgrade ]; then
if [ -d /run/systemd/system ]; then
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
case "$mc_node_device_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
esac
mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true)
case "$mc_node_environment_state" in
active|activating)
+7
View File
@@ -1,6 +1,11 @@
#!/bin/sh
set -eu
if [ -d /run/systemd/system ]; then
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
case "$mc_node_device_job" in
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
esac
mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true)
case "$mc_node_environment_state" in
active|activating)
@@ -29,6 +34,8 @@ case "$1" in
/usr/sbin/sshd -t
systemctl try-reload-or-restart ssh.service
fi
systemctl stop mission-core-realsense.service
systemctl disable mission-core-realsense.service || true
systemctl stop mission-core-node.service
systemctl disable mission-core-node.service
fi
@@ -0,0 +1,154 @@
{
"schema": "missioncore.node.driver-bundle/v1",
"model_id": "realsense.d455",
"revision": "c70509ac57b917dbdd5707a9",
"python": "3.12",
"platform": "linux-amd64",
"wheels": [
{
"name": "aiohappyeyeballs-2.7.1-py3-none-any.whl",
"sha256": "9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472",
"bytes": 15038
},
{
"name": "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"sha256": "fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545",
"bytes": 1719929
},
{
"name": "aioice-0.10.2-py3-none-any.whl",
"sha256": "14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf",
"bytes": 24875
},
{
"name": "aiortc-1.14.0-py3-none-any.whl",
"sha256": "4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e",
"bytes": 93183
},
{
"name": "aiosignal-1.4.0-py3-none-any.whl",
"sha256": "053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e",
"bytes": 7490
},
{
"name": "annotated_types-0.8.0-py3-none-any.whl",
"sha256": "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0",
"bytes": 13427
},
{
"name": "attrs-26.1.0-py3-none-any.whl",
"sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
"bytes": 67548
},
{
"name": "av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl",
"sha256": "7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2",
"bytes": 41174337
},
{
"name": "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
"sha256": "c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf",
"bytes": 221822
},
{
"name": "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
"sha256": "ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef",
"bytes": 4712478
},
{
"name": "dnspython-2.8.0-py3-none-any.whl",
"sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af",
"bytes": 331094
},
{
"name": "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl",
"sha256": "494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383",
"bytes": 242411
},
{
"name": "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl",
"sha256": "14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411",
"bytes": 33364
},
{
"name": "idna-3.19-py3-none-any.whl",
"sha256": "815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4",
"bytes": 68550
},
{
"name": "ifaddr-0.2.0-py3-none-any.whl",
"sha256": "085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748",
"bytes": 12314
},
{
"name": "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
"sha256": "bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961",
"bytes": 256322
},
{
"name": "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"sha256": "fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249",
"bytes": 16527618
},
{
"name": "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
"sha256": "67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6",
"bytes": 7644652
},
{
"name": "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
"sha256": "6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476",
"bytes": 61639
},
{
"name": "pycparser-3.0-py3-none-any.whl",
"sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992",
"bytes": 48172
},
{
"name": "pydantic-2.11.7-py3-none-any.whl",
"sha256": "dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b",
"bytes": 444782
},
{
"name": "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
"sha256": "8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1",
"bytes": 2002028
},
{
"name": "pyee-14.0.0-py3-none-any.whl",
"sha256": "3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d",
"bytes": 15553
},
{
"name": "pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl",
"sha256": "293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc",
"bytes": 2434534
},
{
"name": "pyopenssl-26.4.0-py3-none-any.whl",
"sha256": "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c",
"bytes": 56026
},
{
"name": "pyrealsense2-2.58.4.10922-cp312-cp312-manylinux1_x86_64.whl",
"sha256": "1e83454cbaf9de50962d78ce3addb0b6a6d8d02259c1f0c64d4a0d527a02bf73",
"bytes": 13094959
},
{
"name": "typing_extensions-4.16.0-py3-none-any.whl",
"sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8",
"bytes": 45571
},
{
"name": "typing_inspection-0.4.4-py3-none-any.whl",
"sha256": "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147",
"bytes": 14750
},
{
"name": "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
"sha256": "f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9",
"bytes": 109835
}
]
}
@@ -0,0 +1,194 @@
"""Fixed model job. No paths, packages, URLs or commands are accepted from clients."""
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
import uuid
import zipfile
from pathlib import Path, PurePosixPath
SHARE = Path("/usr/share/mission-core-node/realsense")
ROOT = Path("/var/lib/mission-core-node-drivers")
REPORT = ROOT / "preparation.json"
STEPS = [
("platform", "Проверка совместимости системы"),
("payload", "Проверка встроенного драйвера"),
("runtime", "Развёртывание драйвера"),
("access", "Настройка доступа к камере"),
("service", "Запуск службы камеры"),
]
def publish(value):
ROOT.mkdir(mode=0o755, exist_ok=True)
if ROOT.is_symlink() or ROOT.stat().st_uid != 0:
raise RuntimeError("Небезопасный каталог драйверов")
ROOT.chmod(0o755)
tmp = ROOT / ".preparation.tmp"
with tmp.open("w") as f:
os.fchmod(f.fileno(), 0o644)
json.dump(value, f, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
tmp.replace(REPORT)
def run(*args):
result = subprocess.run(args, capture_output=True, timeout=90)
if result.returncode:
raise RuntimeError("Системный этап не завершён. Повторите подготовку устройства.")
def safe_members(archive):
for info in archive.infolist():
path = PurePosixPath(info.filename)
if (
path.is_absolute()
or ".." in path.parts
or (info.external_attr >> 16) & 0o170000 == 0o120000
):
raise RuntimeError("Недопустимое содержимое драйверного пакета")
if ".data" in info.filename or info.filename.endswith(".pth"):
raise RuntimeError("Пакет требует неподдерживаемый способ установки")
yield info
def prepare():
manifest = json.loads((SHARE / "bundle.json").read_text())
revision = manifest["revision"]
if not revision.isalnum():
raise RuntimeError("Некорректная версия драйвера")
target = ROOT / revision
state = {
"schema": "missioncore.node.device-preparation/v1",
"model_id": "realsense.d455",
"revision": revision,
"run_id": str(uuid.uuid4()),
"started_at": time.time(),
"state": "running",
"steps": [{"id": k, "label": v, "state": "pending"} for k, v in STEPS],
}
publish(state)
try:
for step in state["steps"]:
step["state"] = "running"
state["updated_at"] = time.time()
publish(state)
if step["id"] == "platform":
release = dict(
line.split("=", 1)
for line in Path("/etc/os-release").read_text().splitlines()
if "=" in line
)
if (
(release.get("ID", "").strip('"'), release.get("VERSION_ID", "").strip('"'))
!= ("ubuntu", "24.04")
or os.uname().machine != "x86_64"
or sys.version_info[:2] != (3, 12)
):
raise RuntimeError("Встроенный драйвер несовместим с этой системой")
elif step["id"] == "payload":
for entry in manifest["wheels"]:
path = SHARE / entry["name"]
if (
path.name != entry["name"]
or path.is_symlink()
or hashlib.sha256(path.read_bytes()).hexdigest() != entry["sha256"]
):
raise RuntimeError(
"Контрольная сумма драйвера не совпала. Переустановите пакет Node."
)
elif step["id"] == "runtime":
if not target.exists():
stage = ROOT / (revision + ".partial")
if stage.exists():
shutil.rmtree(stage)
stage.mkdir(mode=0o755)
for entry in manifest["wheels"]:
with zipfile.ZipFile(SHARE / entry["name"]) as archive:
archive.extractall(stage, members=safe_members(archive))
for path in stage.rglob("*"):
path.chmod(0o755 if path.is_dir() else 0o644)
stage.rename(target)
# Wheel RECORD content is checked again against the bundled wheel;
# a previous successful report never substitutes for integrity.
for entry in manifest["wheels"]:
with zipfile.ZipFile(SHARE / entry["name"]) as archive:
for info in safe_members(archive):
if not info.is_dir() and (
target / info.filename
).read_bytes() != archive.read(info):
raise RuntimeError(
"Установленный драйвер изменён. "
"Нужна переустановка пакета драйвера."
)
(ROOT / "active.path").write_text(str(target))
(ROOT / "active.path").chmod(0o644)
elif step["id"] == "access":
source = SHARE / "70-mission-core-realsense.rules"
dest = Path("/etc/udev/rules.d/70-mission-core-realsense.rules")
if dest.is_symlink() or (
dest.exists() and dest.read_bytes() != source.read_bytes()
):
raise RuntimeError(
"Правила доступа к камере изменены в системе. Чужая конфигурация сохранена."
)
dest.write_bytes(source.read_bytes())
dest.chmod(0o644)
run("/usr/bin/udevadm", "control", "--reload-rules")
# Restrict trigger to the admitted product; no unrelated USB reset.
run(
"/usr/bin/udevadm",
"trigger",
"--action=change",
"--subsystem-match=usb",
"--attr-match=idVendor=8086",
"--attr-match=idProduct=0b5c",
)
run(
"/usr/bin/udevadm",
"trigger",
"--action=change",
"--subsystem-match=video4linux",
)
run("/usr/bin/udevadm", "trigger", "--action=change", "--subsystem-match=hidraw")
run("/usr/bin/udevadm", "settle", "--timeout=10")
elif step["id"] == "service":
run("/usr/bin/systemctl", "enable", "mission-core-realsense.service")
# Never restart a running acquisition on repeated preparation.
run("/usr/bin/systemctl", "start", "mission-core-realsense.service")
run("/usr/bin/systemctl", "is-active", "--quiet", "mission-core-realsense.service")
step["state"] = "complete"
publish(state)
state["state"] = "complete"
except (
OSError,
ValueError,
RuntimeError,
subprocess.SubprocessError,
zipfile.BadZipFile,
) as error:
step["state"] = "error"
step["message"] = (
str(error)[:300]
if isinstance(error, RuntimeError)
else "Не удалось подготовить драйвер. Повторите действие."
)
state["state"] = "error"
for item in state["steps"]:
if item["state"] == "pending":
item["state"] = "blocked"
state["updated_at"] = time.time()
publish(state)
return state["state"] == "complete"
if __name__ == "__main__":
os.umask(0o022)
if os.geteuid() != 0 or len(sys.argv) != 1:
sys.exit(1)
sys.exit(0 if prepare() else 1)
@@ -0,0 +1,25 @@
import io
import unittest
import zipfile
from realsense_prepare import safe_members
class BundleBoundaryTests(unittest.TestCase):
def test_archives_cannot_escape_or_execute_import_hooks(self):
for name in ('../../etc/shadow', '/root/escape', 'something.pth', 'sdk.data/purelib/code.py'):
value = io.BytesIO()
with zipfile.ZipFile(value, 'w') as archive:
archive.writestr(name, b'payload')
with zipfile.ZipFile(io.BytesIO(value.getvalue())) as archive:
with self.assertRaises(RuntimeError):
list(safe_members(archive))
def test_archive_symlink_is_rejected(self):
value = io.BytesIO()
with zipfile.ZipFile(value, 'w') as archive:
info = zipfile.ZipInfo('device.py')
info.external_attr = 0o120777 << 16
archive.writestr(info, '/etc/shadow')
with zipfile.ZipFile(io.BytesIO(value.getvalue())) as archive:
with self.assertRaises(RuntimeError):
list(safe_members(archive))
+13
View File
@@ -0,0 +1,13 @@
"""Isolated interpreter; only root-owned pinned runtime and product code on sys.path."""
import sys
from pathlib import Path
root = Path("/var/lib/mission-core-node-drivers")
path = Path((root / "active.path").read_text())
if path.parent != root or not path.name.isalnum() or path.is_symlink() or path.stat().st_uid != 0:
raise RuntimeError("Invalid driver installation")
sys.path[:0] = [str(path), "/usr/lib/mission-core-node/sensors", "/usr/lib/mission-core-node/sdk"]
from server import main # noqa: E402 — use only the verified isolated runtime above
main()
+503
View File
@@ -0,0 +1,503 @@
"""D455 adapter. Hardware ownership and raw acquisition remain on the board."""
import hashlib
import json
import math
import os
import queue
import shutil
import threading
import time
import uuid
from contextlib import suppress
from datetime import UTC, datetime
import numpy as np
import pyrealsense2 as rs
from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
MODEL = {
"plugin_id": "missioncore.realsense",
"plugin_version": "0.6.0",
"model_id": "realsense.d455",
}
def utc():
return datetime.now(UTC).isoformat()
def device_id(serial):
return "rsd455_" + hashlib.sha256(serial.encode()).hexdigest()[:32]
def atomic(path, value):
tmp = path.with_suffix(".tmp")
with tmp.open("w") as f:
json.dump(value, f, ensure_ascii=False, allow_nan=False)
f.flush()
os.fsync(f.fileno())
tmp.replace(path)
def kind(profile):
return str(profile.stream_type()).split(".")[-1]
class Device:
def __init__(self, serial, root, execution):
self.serial = serial
self.id = device_id(serial)
self.root = root / self.id
self.root.mkdir(exist_ok=True, mode=0o700)
self.lock = threading.RLock()
self.execution = execution
self.session = "sensor_" + uuid.uuid4().hex
self.opened = utc()
self.revision = 0
self.online = True
self.acquisition = "idle"
self.message = ""
self.pipeline = None
self.thread = None
self.queue = queue.Queue(maxsize=2)
self.stop_event = threading.Event()
self.images = {}
self.motion = {}
self.depth = None
self.intrinsics = None
self.frames = {}
self.last_frame = None
self.record = None
self.profiles = []
self.options = []
self.sdk_device = None
self.firmware = ""
self.transport = ""
self.config = {"name": "RealSense D455", "verified": False}
if (self.root / "config.json").exists():
self.config.update(json.loads((self.root / "config.json").read_text()))
# A previous verification is evidence, not a live readiness assertion.
self.verified_this_process = False
for path in self.root.glob("recordings/*/manifest.json"):
value = json.loads(path.read_text())
if value.get("state") == "recording":
value.update(state="interrupted", recovered_at=utc())
atomic(path, value)
def save(self):
atomic(self.root / "config.json", self.config)
def refresh(self, dev):
with self.lock:
self.sdk_device = dev
self.online = True
self.firmware = dev.get_info(rs.camera_info.firmware_version)
self.transport = dev.get_info(rs.camera_info.usb_type_descriptor)
if self.pipeline is not None:
return
profiles, options = [], []
for index, sensor in enumerate(dev.query_sensors()):
for profile in sensor.get_stream_profiles():
stream = kind(profile)
if stream not in ("color", "depth", "infrared", "accel", "gyro"):
continue
data = {
"sensor": index,
"stream": stream,
"index": profile.stream_index(),
"fps": profile.fps(),
"format": str(profile.format()).split(".")[-1],
}
if profile.is_video_stream_profile():
video = profile.as_video_stream_profile()
data.update(width=video.width(), height=video.height())
data["id"] = hashlib.sha256(
json.dumps(data, sort_keys=True).encode()
).hexdigest()[:16]
profiles.append(data)
for option in sensor.get_supported_options():
try:
limits = sensor.get_option_range(option)
value = sensor.get_option(option)
if not all(
math.isfinite(x) for x in (limits.min, limits.max, limits.step, value)
):
continue
options.append(
{
"id": f"{index}:{int(option)}",
"sensor": sensor.get_info(rs.camera_info.name),
"label": str(option).split(".")[-1],
"value": value,
"min": limits.min,
"max": limits.max,
"step": limits.step,
"read_only": sensor.is_option_read_only(option),
}
)
except RuntimeError:
continue
self.profiles, self.options = profiles, options
def defaults(self):
result = []
for stream, index, fmt, fps in [
("depth", 0, "z16", 15),
("color", 0, "rgb8", 15),
("infrared", 1, "y8", 15),
("infrared", 2, "y8", 15),
("accel", 0, "motion_xyz32f", 63),
("gyro", 0, "motion_xyz32f", 200),
]:
candidates = [
p
for p in self.profiles
if p["stream"] == stream and p["index"] == index and p["format"] == fmt
]
if candidates:
p = min(
candidates,
key=lambda p: (
abs(p.get("width", 640) - 640)
+ abs(p.get("height", 480) - 480)
+ abs(p["fps"] - fps) * 20
),
)
result.append(p["id"])
return result
def snapshot(self, detailed=True):
with self.lock:
now = utc()
snap = DeviceSessionSnapshot.model_validate(
{
"context": {
"session_id": self.session,
"device": {
"device_id": self.id,
"model": MODEL,
"stability": "stable",
"basis": "hardware-identifier",
},
"execution": self.execution,
"opened_at": self.opened,
},
"revision": self.revision,
"enrollment": "enrolled" if self.config["verified"] else "empty",
"connectivity": "connected" if self.online else "offline",
"acquisition": self.acquisition,
"observed_at": now,
"message": self.message or None,
}
)
value = {
"id": self.id,
"name": self.config["name"],
"model": "RealSense D455",
"prepared": True,
"verified": self.verified_this_process,
"online": self.online,
"snapshot": snap.model_dump(mode="json"),
"firmware": self.firmware,
"usb": self.transport,
"frames": dict(self.frames),
"last_frame": self.last_frame,
"recording": self.record,
"layers": list(self.images)
+ (["points"] if self.depth is not None else [])
+ (["motion"] if self.motion else []),
}
if detailed:
value.update(
profiles=self.profiles,
defaults=self.defaults(),
options=self.options,
recordings=self.recordings(),
motion=dict(self.motion),
)
return value
def callback(self, frame):
if frame.is_motion_frame():
v = frame.as_motion_frame().get_motion_data()
key = kind(frame.profile)
self.motion[key] = {
"x": v.x,
"y": v.y,
"z": v.z,
"timestamp_ms": frame.get_timestamp(),
"clock": str(frame.get_frame_timestamp_domain()),
"observed_at": utc(),
}
self.frames[key] = self.frames.get(key, 0) + 1
elif frame.is_frameset():
with suppress(queue.Full):
self.queue.put_nowait(frame.as_frameset())
def start(self, selected=None, record=False):
with self.lock:
if self.pipeline is not None:
raise ValueError("Захват уже запущен. Сначала остановите его.")
if not self.online or self.sdk_device is None:
raise ValueError("Камера не подключена.")
ids = selected if selected is not None else self.defaults()
if not isinstance(ids, list) or not 1 <= len(ids) <= 6 or len(set(ids)) != len(ids):
raise ValueError("Выберите профили потоков.")
profiles = [next((p for p in self.profiles if p["id"] == ident), None) for ident in ids]
if any(p is None for p in profiles) or len(
{(p["stream"], p["index"]) for p in profiles}
) != len(profiles):
raise ValueError("Выбраны несовместимые профили потоков.")
if not any(p["stream"] in ("depth", "color", "infrared") for p in profiles):
raise ValueError("Выберите хотя бы один видеопоток.")
config = rs.config()
config.enable_device(self.serial)
for p in profiles:
stream, fmt = getattr(rs.stream, p["stream"]), getattr(rs.format, p["format"])
if "width" in p:
config.enable_stream(stream, p["index"], p["width"], p["height"], fmt, p["fps"])
else:
config.enable_stream(stream, p["index"], fmt, p["fps"])
pipeline = rs.pipeline()
if not config.can_resolve(rs.pipeline_wrapper(pipeline)):
raise ValueError("Камера не поддерживает эту комбинацию профилей. Выберите другую.")
self.acquisition = "starting"
self.revision += 1
self.images, self.motion, self.frames = {}, {}, {}
self.depth, self.last_frame = None, None
self.queue = queue.Queue(maxsize=2)
self.stop_event.clear()
record_path = None
if record:
if shutil.disk_usage(self.root).free < 2 * 1024**3:
self.acquisition = "idle"
raise ValueError("Для исходной записи нужно не меньше 2 ГиБ свободного места.")
ident = "capture_" + uuid.uuid4().hex
record_path = self.root / "recordings" / ident
record_path.mkdir(parents=True, mode=0o700)
config.enable_record_to_file(str(record_path / "source.bag"))
self.record = {
"id": ident,
"state": "recording",
"started_at": utc(),
"monotonic_ns": time.monotonic_ns(),
"device_id": self.id,
"session_id": self.session,
"profiles": profiles,
"firmware": self.firmware,
"sdk": "2.58.4.10922",
"options": self.options,
}
atomic(record_path / "manifest.json", self.record)
try:
active = pipeline.start(config, self.callback)
self.pipeline = pipeline
self.acquisition = "streaming"
self.message = ""
self.depth_scale = active.get_device().first_depth_sensor().get_depth_scale()
calibration = []
for p in active.get_streams():
if p.is_video_stream_profile():
v = p.as_video_stream_profile().get_intrinsics()
calibration.append(
{
"stream": kind(p),
"index": p.stream_index(),
"width": v.width,
"height": v.height,
"fx": v.fx,
"fy": v.fy,
"ppx": v.ppx,
"ppy": v.ppy,
"coeffs": v.coeffs,
"model": str(v.model),
}
)
if self.record:
self.record["calibration"] = calibration
self.record["depth_scale"] = self.depth_scale
atomic(record_path / "manifest.json", self.record)
self.thread = threading.Thread(target=self.consume, daemon=True)
self.thread.start()
except Exception:
with suppress(RuntimeError):
pipeline.stop()
self.pipeline = None
self.acquisition = "failed"
self.message = "Не удалось открыть потоки камеры. Проверьте подключение и профили."
if self.record:
self.record.update(state="failed", ended_at=utc())
atomic(record_path / "manifest.json", self.record)
self.record = None
raise ValueError(self.message) from None
def consume(self):
colorizer = rs.colorizer()
last_data, last_disk = time.monotonic(), time.monotonic()
while not self.stop_event.is_set():
try:
frames = self.queue.get(timeout=1)
except queue.Empty:
if time.monotonic() - last_data > 8:
self.message = "Кадры перестали поступать. Проверьте USB и остановите захват."
self.acquisition = "failed"
self.online = False
break
continue
last_data = time.monotonic()
for frame in frames:
key = kind(frame.profile)
if key == "infrared":
key += str(frame.profile.stream_index())
if not frame.is_video_frame():
continue
data = np.asanyarray(frame.get_data()).copy()
if key == "depth":
self.depth = data
self.intrinsics = frame.profile.as_video_stream_profile().get_intrinsics()
data = np.asanyarray(colorizer.colorize(frame).get_data()).copy()
elif data.ndim == 2:
data = np.repeat(data[:, :, None], 3, axis=2)
elif frame.profile.format() == rs.format.bgr8:
data = data[:, :, ::-1].copy()
elif frame.profile.format() != rs.format.rgb8:
continue
self.images[key] = data
self.frames[key] = self.frames.get(key, 0) + 1
self.last_frame = {
"observed_at": utc(),
"monotonic_ns": time.monotonic_ns(),
"device_timestamp_ms": frame.get_timestamp(),
"clock": str(frame.get_frame_timestamp_domain()),
}
if self.record and time.monotonic() - last_disk > 2:
last_disk = time.monotonic()
if shutil.disk_usage(self.root).free < 512 * 1024**2:
self.message = "Запись остановлена: мало свободного места."
break
if not self.stop_event.is_set():
# Explicit device/disk failure closes raw recording; never auto-restart.
self.stop(failed=True, from_capture=True)
def stop(self, failed=False, from_capture=False):
with self.lock:
self.stop_event.set()
pipeline, self.pipeline = self.pipeline, None
if pipeline is not None:
self.acquisition = "stopping"
pipeline.stop()
if self.thread and not from_capture:
self.thread.join(timeout=3)
self.acquisition = "failed" if failed else "idle"
self.revision += 1
if self.record:
value = dict(self.record)
value.update(
state="failed" if failed else "complete",
ended_at=utc(),
ended_monotonic_ns=time.monotonic_ns(),
frames=dict(self.frames),
)
path = self.root / "recordings" / value["id"]
source = path / "source.bag"
if source.exists():
digest = hashlib.sha256()
with source.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
value.update(sha256=digest.hexdigest(), bytes=source.stat().st_size)
atomic(path / "manifest.json", value)
self.record = None
return {"ok": True}
def verify(self):
if self.pipeline is not None:
raise ValueError("Остановите захват перед повторной проверкой.")
try:
self.start()
deadline = time.monotonic() + 8
expected = {
p["stream"] + (str(p["index"]) if p["stream"] == "infrared" else "")
for p in self.profiles
if p["id"] in self.defaults()
}
while time.monotonic() < deadline:
if all(self.frames.get(key, 0) >= 2 for key in expected):
self.config["verified"] = True
self.verified_this_process = True
self.config["verified_at"] = utc()
self.save()
return {
"ok": True,
"frames": dict(self.frames),
"verified_at": self.config["verified_at"],
}
time.sleep(0.1)
raise ValueError(
"Не получены кадры всех выбранных потоков. Проверьте USB 3 и повторите проверку."
)
finally:
self.stop()
def rename(self, name):
if (
not isinstance(name, str)
or not name.strip()
or len(name) > 80
or any(ord(c) < 32 for c in name)
):
raise ValueError("Введите название до 80 символов.")
with self.lock:
self.config["name"] = name.strip()
self.save()
self.revision += 1
return {"ok": True}
def set_option(self, identifier, value):
with self.lock:
item = next((v for v in self.options if v["id"] == identifier), None)
if (
not item
or item["read_only"]
or type(value) not in (int, float)
or not math.isfinite(value)
or not item["min"] <= value <= item["max"]
):
raise ValueError("Параметр недоступен или значение вне диапазона.")
sensor_index, option_id = map(int, identifier.split(":"))
sensor = self.sdk_device.query_sensors()[sensor_index]
option = rs.option(option_id)
sensor.set_option(option, float(value))
item["value"] = sensor.get_option(option)
self.revision += 1
return {"ok": True, "value": item["value"]}
def points(self):
depth, intrinsics = self.depth, self.intrinsics
if depth is None or intrinsics is None:
return []
# SDK deprojection respects the camera's actual distortion model.
h, w = depth.shape
stride = max(8, math.ceil(math.sqrt(h * w / 2000)))
result = []
for y in range(0, h, stride):
for x in range(0, w, stride):
z = float(depth[y, x]) * self.depth_scale
if 0 < z < 15:
result.extend(
round(v, 4) for v in rs.rs2_deproject_pixel_to_point(intrinsics, [x, y], z)
)
return result
def recordings(self):
result = []
for path in sorted(self.root.glob("recordings/*/manifest.json"), reverse=True)[:100]:
value = json.loads(path.read_text())
result.append(
{
k: value.get(k)
for k in ("id", "state", "started_at", "ended_at", "bytes", "sha256")
}
)
return result
+126
View File
@@ -0,0 +1,126 @@
"""Private WebRTC preview. No capture ownership, relay, STUN or public candidates."""
import asyncio
import ipaddress
import json
import time
import uuid
import aioice.ice
from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
from av import VideoFrame
def private(address):
try:
value = ipaddress.ip_address(address)
return value.version == 4 and (
value.is_loopback
or any(
value in ipaddress.ip_network(n)
for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10")
)
)
except ValueError:
return False
host_addresses = aioice.ice.get_host_addresses
aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [
v for v in host_addresses(use_ipv4=True, use_ipv6=False) if private(v)
]
class CameraTrack(VideoStreamTrack):
def __init__(self, device, layer):
super().__init__()
self.device, self.layer = device, layer
async def recv(self):
pts, base = await self.next_timestamp()
# Bound preview to 15 Hz; hardware profiles and raw recording are independent.
await asyncio.sleep(1 / 30)
while self.layer not in self.device.images:
await asyncio.sleep(0.1)
frame = VideoFrame.from_ndarray(self.device.images[self.layer], format="rgb24")
frame.pts, frame.time_base = pts, base
return frame
class Peers:
def __init__(self):
self.items = {}
async def offer(self, device, params):
layer = params.get("layer", "color")
if layer not in ("color", "depth", "infrared1", "infrared2", "points", "motion"):
raise ValueError("Неизвестный слой камеры.")
if len(self.items) >= 4:
raise ValueError("Закройте лишние окна просмотра камеры.")
sdp = params.get("sdp", "")
if not isinstance(sdp, str) or len(sdp) > 32768:
raise ValueError("Некорректное приглашение просмотра.")
for line in sdp.splitlines():
if line.startswith("a=candidate:"):
fields = line.split()
if len(fields) < 8 or (not private(fields[4]) and not fields[4].endswith(".local")):
raise ValueError("Просмотр доступен только в частной сети.")
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
ident = "peer_" + uuid.uuid4().hex
self.items[ident] = {"pc": pc, "seen": time.monotonic()}
async def telemetry(channel):
try:
while pc.connectionState not in ("failed", "closed"):
if time.monotonic() - self.items.get(ident, {}).get("seen", 0) > 30:
break
if channel.readyState == "open" and channel.bufferedAmount < 65536:
payload = {
"layer": layer,
"motion": device.motion,
"frame": device.last_frame,
"acquisition": device.acquisition,
}
if layer == "points":
payload["points"] = await asyncio.to_thread(device.points)
channel.send(json.dumps(payload, allow_nan=False))
await asyncio.sleep(0.25)
finally:
await self.close(ident)
@pc.on("datachannel")
def datachannel(channel):
@channel.on("message")
def message(value):
if value == "keepalive" and ident in self.items:
self.items[ident]["seen"] = time.monotonic()
asyncio.create_task(telemetry(channel))
@pc.on("connectionstatechange")
async def changed():
if pc.connectionState in ("failed", "closed"):
self.items.pop(ident, None)
try:
await pc.setRemoteDescription(RTCSessionDescription(sdp=sdp, type="offer"))
if layer not in ("points", "motion"):
pc.addTrack(CameraTrack(device, layer))
await pc.setLocalDescription(await pc.createAnswer())
async def expiry():
await asyncio.sleep(30)
if ident in self.items and pc.connectionState != "connected":
await self.close(ident)
asyncio.create_task(expiry())
return {"peer_id": ident, "sdp": pc.localDescription.sdp, "type": "answer"}
except Exception:
await self.close(ident)
raise
async def close(self, ident):
entry = self.items.pop(ident, None)
if entry:
await entry["pc"].close()
return {"ok": True}
+161
View File
@@ -0,0 +1,161 @@
"""Private Unix plugin host; the Go broker is the only UI/Core authority."""
import asyncio
import hashlib
import json
import os
import re
import uuid
from datetime import UTC, datetime
from pathlib import Path
import pyrealsense2 as rs
from aiohttp import web
from device import Device, atomic, device_id
from media import Peers
from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest
ROOT = Path("/var/lib/mission-core-sensors")
SOCKET = Path("/run/mission-core-sensors/driver.sock")
class Host:
def __init__(self, root=ROOT):
self.root = root
self.context = rs.context()
self.devices = {}
self.execution = None
self.peers = Peers()
self.scan_lock = asyncio.Lock()
self.operation_locks = {}
async def scan(self):
if self.execution is None:
return
async with self.scan_lock:
def work():
found = set()
for dev in self.context.query_devices():
if dev.get_info(rs.camera_info.product_id).lower() != "0b5c":
continue
serial = dev.get_info(rs.camera_info.serial_number)
ident = device_id(serial)
found.add(ident)
if ident not in self.devices:
self.devices[ident] = Device(serial, self.root, self.execution)
self.devices[ident].refresh(dev)
for ident, device in self.devices.items():
if ident not in found:
device.online = False
await asyncio.to_thread(work)
async def inventory(self, request):
node_id = request.headers.get("X-Node-Id", "")
if not re.fullmatch(r"node_[0-9a-f]{64}", node_id):
raise web.HTTPForbidden()
if self.execution is None:
self.execution = {
"node_id": node_id,
"agent_instance_id": "driver_" + uuid.uuid4().hex,
"platform": "linux",
}
if self.execution["node_id"] != node_id:
raise web.HTTPForbidden()
await self.scan()
return web.json_response(
{"items": [device.snapshot(False) for device in self.devices.values()]}
)
async def operation(self, request):
value = await request.json()
command = OperationRequest.model_validate(value)
identifier = command.operation_id
if not re.fullmatch(r"op_[0-9a-f]{32}", identifier):
raise ValueError("Некорректный идентификатор операции.")
path = self.root / (identifier + ".json")
digest = hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
lock = self.operation_locks.setdefault(identifier, asyncio.Lock())
async with lock:
if path.exists():
previous = json.loads(path.read_text())
if previous["digest"] != digest:
raise ValueError("Идентификатор операции уже использован.")
return web.json_response(previous["result"])
if command.deadline_at <= datetime.now(UTC):
raise ValueError("Срок команды истёк. Состояние камеры не изменено.")
device = self.devices.get(command.session.device_id)
if device is None or command.session.session_id != device.session:
raise ValueError("Сеанс камеры изменился. Обновите сведения.")
# Persist uncertainty before any side effect. A crash must not replay START.
receipt = {
"digest": digest,
"result": {
"state": "unknown",
"error": "Результат операции пока неизвестен. Обновите состояние устройства.",
},
}
atomic(path, receipt)
try:
action, params = command.action_id, dict(command.parameters)
if action == "details":
result = device.snapshot()
elif action == "rename":
result = device.rename(params.get("name"))
elif action == "verify":
result = await asyncio.to_thread(device.verify)
elif action == "start":
if type(params.get("record", False)) is not bool:
raise ValueError("Некорректный режим записи.")
await asyncio.to_thread(
device.start, params.get("profiles"), params.get("record", False)
)
result = {"ok": True}
elif action == "stop":
result = await asyncio.to_thread(device.stop)
elif action == "option":
result = await asyncio.to_thread(
device.set_option, params.get("id"), params.get("value")
)
elif action == "offer":
result = await self.peers.offer(device, params)
elif action == "close-peer":
result = await self.peers.close(params.get("peer_id"))
else:
raise ValueError("Неподдерживаемая операция устройства.")
receipt["result"] = {"state": "complete", "result": result}
except (ValueError, RuntimeError) as error:
receipt["result"] = {"state": "error", "error": str(error)[:400]}
atomic(path, receipt)
return web.json_response(receipt["result"])
async def cleanup(self, app):
for peer in list(self.peers.items):
await self.peers.close(peer)
for device in self.devices.values():
await asyncio.to_thread(device.stop, True)
@web.middleware
async def errors(request, handler):
try:
return await handler(request)
except (ValueError, KeyError, TypeError):
return web.json_response({"error": "Некорректная команда устройства."}, status=400)
except RuntimeError:
return web.json_response(
{"error": "Драйвер не смог выполнить запрос. Проверьте подключение камеры."}, status=409
)
def main():
os.umask(0o007)
host = Host()
app = web.Application(client_max_size=65536, middlewares=[errors])
app.router.add_get("/inventory", host.inventory)
app.router.add_post("/operation", host.operation)
app.on_cleanup.append(host.cleanup)
if SOCKET.exists():
SOCKET.unlink()
web.run_app(app, path=str(SOCKET), print=None, access_log=None, shutdown_timeout=20)
+5
View File
@@ -0,0 +1,5 @@
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
import {request} from './api';
const transport:SensorTransport={inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
export function NodeSensors(){return <SensorWorkspace transport={transport}/>;}
+2
View File
@@ -15,6 +15,7 @@ import { useEnvironment } from "./useEnvironment";
import { EnvironmentView } from "./EnvironmentView";
import { CoreConnectionView } from "./CoreConnectionView";
import "./node.css";
import { NodeSensors } from "./NodeSensors";
function App() {
const node = useNode();
@@ -32,6 +33,7 @@ function App() {
function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); }
function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); }
const content = !value ? null : workspace.activeView === "environment" ? <EnvironmentView environment={environment} failure={failure} success={node.success} /> : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} />
: workspace.activeView === "sensors" ? <NodeSensors />
: workspace.activeView === "network" ? <NetworkView value={value} />
: workspace.activeView === "usb" ? <USBView value={value} />
: workspace.activeView === "core" ? <CoreConnectionView failure={failure} />
+3 -2
View File
@@ -1,11 +1,12 @@
import type { IconName } from "@nodedc/ui-react";
export type RootId = "system" | "devices";
export type ViewId = "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh" | "core";
export type ViewId = "sensors" | "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh" | "core";
export const roots: { id: RootId; label: string; first: ViewId | null }[] = [
{ id: "system", label: "Система", first: "environment" },
{ id: "devices", label: "Устройства", first: null },
{ id: "devices", label: "Устройства", first: "sensors" },
];
export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[] = [
{ id: "sensors", root: "devices", label: "Подключённые устройства", icon: "camera" },
{ id: "environment", root: "system", label: "Настройка окружения", icon: "settings" },
{ id: "overview", root: "system", label: "Обзор БК", icon: "activity" },
{ id: "network", root: "system", label: "Сеть", icon: "network" },
+27 -4
View File
@@ -1,8 +1,31 @@
{
"compilerOptions": {
"target": "ES2022", "lib": ["ES2022", "DOM"], "module": "ESNext",
"moduleResolution": "bundler", "jsx": "react-jsx", "strict": true,
"skipLibCheck": true, "noEmit": true, "esModuleInterop": true
"target": "ES2022",
"lib": [
"ES2022",
"DOM"
],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"baseUrl": ".",
"paths": {
"react": [
"node_modules/@types/react/index.d.ts"
],
"react/jsx-runtime": [
"node_modules/@types/react/jsx-runtime.d.ts"
],
"@nodedc/ui-react": [
"node_modules/@nodedc/ui-react/dist/index.d.ts"
]
}
},
"include": ["src"]
"include": [
"src"
]
}
+1 -1
View File
@@ -3,5 +3,5 @@ import { defineConfig } from "vite";
export default defineConfig({
// Design Guideline packages are linked during development. Their own React
// must never become a second hook dispatcher in the portable production bundle.
resolve: { dedupe: ["react", "react-dom"] },
resolve: { dedupe: ["react", "react-dom", "@nodedc/ui-react"] },
});