Add D455 sensor host and shared Node/Core preparation surface
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ©, 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 ©, 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 ©
|
||||
}
|
||||
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")}}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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}/>;}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"] },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user