Add packaged Insta360 X4 integration and recover paired Node channels
Discover independent camera instances and prepare their versioned runtime from Node or remote Core. Add isolated SDK workers, camera controls, raw dual-fisheye WebRTC preview, and shared action/region loading states. Recover existing Node bindings over known Tailscale addresses after a Core LAN address change. Preserve identities and trust, pin both peers, migrate endpoints with revision checks, and require real heartbeats for online status. Fix the Python client certificate profile for Go X509 verification. Pin Design Guideline 8c53f73 and retain installer/build/acceptance history. Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending. Validation: qualified DG/Node builds and Go race tests; 31 fleet tests; Python-to-Go certificate interoperability and live tailnet recovery with five fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance. Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -21,15 +22,16 @@ type Invitation struct {
|
||||
SecretHash string `json:"secret_hash,omitempty"`
|
||||
}
|
||||
type CoreBinding struct {
|
||||
BindingID string `json:"binding_id"`
|
||||
CoreID string `json:"core_id"`
|
||||
CoreName string `json:"core_name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CAPEM string `json:"ca_pem"`
|
||||
ClientPEM string `json:"client_pem"`
|
||||
Receipt string `json:"receipt,omitempty"`
|
||||
OfferHash string `json:"offer_hash,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
EndpointRevision uint64 `json:"endpoint_revision,omitempty"`
|
||||
BindingID string `json:"binding_id"`
|
||||
CoreID string `json:"core_id"`
|
||||
CoreName string `json:"core_name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CAPEM string `json:"ca_pem"`
|
||||
ClientPEM string `json:"client_pem"`
|
||||
Receipt string `json:"receipt,omitempty"`
|
||||
OfferHash string `json:"offer_hash,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
type PairState struct {
|
||||
Schema string `json:"schema"`
|
||||
@@ -171,6 +173,12 @@ func (p *Pairing) addresses() []string {
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
if tailnetAddress(result[i]) != tailnetAddress(result[j]) {
|
||||
return tailnetAddress(result[i])
|
||||
}
|
||||
return result[i] < result[j]
|
||||
})
|
||||
return result
|
||||
}
|
||||
func (p *Pairing) invite(address string) (map[string]any, error) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
const recoverySchema = "missioncore.node-channel-recovery/v1"
|
||||
|
||||
// Address preference is transport policy, never proof of peer identity.
|
||||
func tailnetAddress(address string) bool {
|
||||
ip := net.ParseIP(address).To4()
|
||||
return ip != nil && ip[0] == 100 && ip[1] >= 64 && ip[1] <= 127
|
||||
}
|
||||
|
||||
func recoveryTLS(b CoreBinding, key ed25519.PrivateKey, address string) (*tls.Config, error) {
|
||||
config, err := bindingTLS(b, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := bootstrapCertificate(key, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: config.RootCAs,
|
||||
VerifyConnection: func(state tls.ConnectionState) error {
|
||||
if !recoveryPeer(state, b.CoreID) {
|
||||
return errors.New("recovery requires the bound Core identity")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func recoveryPeer(state tls.ConnectionState, coreID string) bool {
|
||||
if len(state.VerifiedChains) == 0 || len(state.PeerCertificates) == 0 {
|
||||
return false
|
||||
}
|
||||
key, ok := state.PeerCertificates[0].PublicKey.(ed25519.PublicKey)
|
||||
return ok && keyID("core_", key) == coreID
|
||||
}
|
||||
|
||||
func (p *Pairing) recoveryHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil || !tailnetAddress(host) || r.TLS == nil {
|
||||
http.Error(w, "Private authenticated channel required", 403)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Schema string `json:"schema"`
|
||||
BindingID string `json:"binding_id"`
|
||||
ExpectedEndpoint string `json:"expected_endpoint,omitempty"`
|
||||
ExpectedRevision uint64 `json:"expected_revision,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
}
|
||||
if !pairDecode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
b := p.state.Binding
|
||||
// Recheck durable authority on every request, including existing TLS sessions.
|
||||
if p.state.Phase != "paired" || b == nil || !recoveryPeer(*r.TLS, b.CoreID) || body.BindingID != b.BindingID {
|
||||
http.Error(w, "Binding unavailable", 403)
|
||||
return
|
||||
}
|
||||
if body.Schema != recoverySchema {
|
||||
http.Error(w, "Incompatible recovery protocol", 400)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v1/channel/inspect":
|
||||
case "/v1/channel/migrate":
|
||||
u, err := url.Parse(body.Endpoint)
|
||||
if err != nil || !privateEndpoint(body.Endpoint, CorePort) || !tailnetAddress(u.Hostname()) || u.Hostname() != host {
|
||||
http.Error(w, "Endpoint must address the authenticated Core transport", 400)
|
||||
return
|
||||
}
|
||||
if body.ExpectedEndpoint != b.Endpoint || body.ExpectedRevision != b.EndpointRevision || b.EndpointRevision >= 1000000000 {
|
||||
http.Error(w, "Endpoint changed; inspect again", 409)
|
||||
return
|
||||
}
|
||||
if body.Endpoint != b.Endpoint {
|
||||
next := p.state
|
||||
copy := *b
|
||||
copy.Endpoint = body.Endpoint
|
||||
copy.EndpointRevision++
|
||||
next.Binding = ©
|
||||
if p.save(next) != nil {
|
||||
http.Error(w, "State unavailable", 503)
|
||||
return
|
||||
}
|
||||
p.connection = "offline"
|
||||
p.lastSeen = 0
|
||||
b = p.state.Binding
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id, _ := p.store.Public()
|
||||
reply(w, 200, map[string]any{"schema": recoverySchema, "node_id": id, "core_id": b.CoreID, "binding_id": b.BindingID, "endpoint": b.Endpoint, "endpoint_revision": b.EndpointRevision})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func recoveryFixture(t *testing.T) (*Pairing, tls.Certificate) {
|
||||
t.Helper()
|
||||
p, _ := testPairing(t)
|
||||
pub, key, _ := ed25519.GenerateKey(rand.Reader)
|
||||
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "synthetic Core"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature}
|
||||
der, err := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca, _ = x509.ParseCertificate(der)
|
||||
leaf := func(serial int64, public any) []byte {
|
||||
spec := &x509.Certificate{SerialNumber: big.NewInt(serial), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, KeyUsage: x509.KeyUsageDigitalSignature}
|
||||
value, e := x509.CreateCertificate(rand.Reader, spec, ca, public, key)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return value
|
||||
}
|
||||
b := CoreBinding{BindingID: token(), CoreID: keyID("core_", pub), CoreName: "Test", Endpoint: "https://192.168.10.5:8782", CAPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), ClientPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf(2, p.store.pairingKey().Public())}))}
|
||||
if err := p.save(PairState{Schema: PairSchema, Phase: "paired", Binding: &b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, tls.Certificate{Certificate: [][]byte{leaf(3, pub)}, PrivateKey: key}
|
||||
}
|
||||
|
||||
func recoveryCall(p *Pairing, client tls.Certificate, path string, body map[string]any) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader(string(raw)))
|
||||
r.RemoteAddr = "100.64.10.5:42000"
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
cert, _ := x509.ParseCertificate(client.Certificate[0])
|
||||
r.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{cert}}, PeerCertificates: []*x509.Certificate{cert}}
|
||||
w := httptest.NewRecorder()
|
||||
p.recoveryHandler().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestRecoveryMigrationPreservesTrustAndSurvivesRestart(t *testing.T) {
|
||||
p, client := recoveryFixture(t)
|
||||
old := *p.state.Binding
|
||||
body := map[string]any{"schema": recoverySchema, "binding_id": old.BindingID, "expected_endpoint": old.Endpoint, "expected_revision": 0, "endpoint": "https://100.64.10.5:8782"}
|
||||
if out := recoveryCall(p, client, "/v1/channel/migrate", body); out.Code != 200 {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
got := *p.state.Binding
|
||||
if got.BindingID != old.BindingID || got.CoreID != old.CoreID || got.CAPEM != old.CAPEM || got.ClientPEM != old.ClientPEM || got.EndpointRevision != 1 {
|
||||
t.Fatal("migration replaced authority")
|
||||
}
|
||||
if out := recoveryCall(p, client, "/v1/channel/migrate", body); out.Code != 409 {
|
||||
t.Fatal("stale request accepted")
|
||||
}
|
||||
reopened, err := OpenPairing(p.store, filepath.Dir(p.path), "test", p.inventory)
|
||||
if err != nil || reopened.state.Binding.Endpoint != got.Endpoint || reopened.state.Binding.EndpointRevision != 1 {
|
||||
t.Fatal("migration not durable", err)
|
||||
}
|
||||
inspect := map[string]any{"schema": recoverySchema, "binding_id": old.BindingID}
|
||||
out := recoveryCall(reopened, client, "/v1/channel/inspect", inspect)
|
||||
if out.Code != 200 || !strings.Contains(out.Body.String(), got.Endpoint) || strings.Contains(out.Body.String(), "PEM") {
|
||||
t.Fatal("lost ack not recoverable")
|
||||
}
|
||||
if err := reopened.cancel(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recoveryCall(reopened, client, "/v1/channel/inspect", inspect).Code != 403 {
|
||||
t.Fatal("revocation bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryRejectsForeignAuthorityAndWrongDestination(t *testing.T) {
|
||||
p, client := recoveryFixture(t)
|
||||
b := *p.state.Binding
|
||||
body := map[string]any{"schema": recoverySchema, "binding_id": b.BindingID, "expected_endpoint": b.Endpoint, "endpoint": "https://100.64.10.5:8782"}
|
||||
_, foreign := recoveryFixture(t)
|
||||
if recoveryCall(p, foreign, "/v1/channel/migrate", body).Code != 403 {
|
||||
t.Fatal("foreign Core accepted")
|
||||
}
|
||||
for _, destination := range []string{"https://100.64.10.6:8782", "https://8.8.8.8:8782", "https://192.168.10.5:8782", "https://100.64.10.5:443", "https://100.64.10.5:8782/path"} {
|
||||
body["endpoint"] = destination
|
||||
if recoveryCall(p, client, "/v1/channel/migrate", body).Code != 400 {
|
||||
t.Fatal("wrong destination accepted", destination)
|
||||
}
|
||||
}
|
||||
if *p.state.Binding != b {
|
||||
t.Fatal("rejection mutated binding")
|
||||
}
|
||||
// A valid Node credential signed by this same CA is not Core authority.
|
||||
block, _ := pem.Decode([]byte(b.ClientPEM))
|
||||
otherNode := tls.Certificate{Certificate: [][]byte{block.Bytes}, PrivateKey: p.store.pairingKey()}
|
||||
if recoveryCall(p, otherNode, "/v1/channel/inspect", body).Code != 403 {
|
||||
t.Fatal("Node impersonated Core")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryTLSRequiresBoundCoreCertificate(t *testing.T) {
|
||||
p, core := recoveryFixture(t)
|
||||
config, err := recoveryTLS(*p.state.Binding, p.store.pairingKey(), "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
|
||||
server.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
server.TLS = config
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
_, foreign := recoveryFixture(t)
|
||||
block, _ := pem.Decode([]byte(p.state.Binding.ClientPEM))
|
||||
node := tls.Certificate{Certificate: [][]byte{block.Bytes}, PrivateKey: p.store.pairingKey()}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
certificates []tls.Certificate
|
||||
accepted bool
|
||||
}{
|
||||
{"Core", []tls.Certificate{core}, true}, {"missing", nil, false}, {"foreign", []tls.Certificate{foreign}, false}, {"Node", []tls.Certificate{node}, false},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13, InsecureSkipVerify: true, Certificates: item.certificates}}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
|
||||
response, e := client.Get(server.URL)
|
||||
if e == nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
if (e == nil) != item.accepted {
|
||||
t.Fatal("TLS admission", e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailnetIsFirstInvitationAddress(t *testing.T) {
|
||||
p, _ := testPairing(t)
|
||||
p.inventory = func() Inventory {
|
||||
return Inventory{Networks: []Network{
|
||||
{Up: true, Addresses: []string{"192.168.10.4/24", "100.64.10.4/32"}},
|
||||
{Up: false, Addresses: []string{"100.64.1.1/32"}},
|
||||
}}
|
||||
}
|
||||
addresses := p.addresses()
|
||||
if len(addresses) != 2 || addresses[0] != "100.64.10.4" {
|
||||
t.Fatal(addresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldHeartbeatCannotRevokeMigratedBinding(t *testing.T) {
|
||||
p, core := recoveryFixture(t)
|
||||
old := *p.state.Binding
|
||||
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
p.clients[old.BindingID+old.Endpoint+digest(old.ClientPEM)] = &http.Client{
|
||||
Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return &http.Response{StatusCode: 410, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil
|
||||
}),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { defer close(done); p.channel(ctx) }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("heartbeat did not start")
|
||||
}
|
||||
body := map[string]any{"schema": recoverySchema, "binding_id": old.BindingID, "expected_endpoint": old.Endpoint, "endpoint": "https://100.64.10.5:8782"}
|
||||
response := recoveryCall(p, core, "/v1/channel/migrate", body)
|
||||
cancel()
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("heartbeat did not stop")
|
||||
}
|
||||
if response.Code != 200 || p.state.Phase != "paired" || p.state.Binding.EndpointRevision != 1 || p.connection != "offline" {
|
||||
t.Fatal("old heartbeat changed new binding")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (p *Pairing) remoteHandler() http.Handler {
|
||||
return
|
||||
}
|
||||
b := *body.Binding
|
||||
if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 {
|
||||
if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 || b.EndpointRevision != 0 {
|
||||
http.Error(w, "Invalid binding", 400)
|
||||
return
|
||||
}
|
||||
@@ -154,6 +154,8 @@ func (p *Pairing) Run(ctx context.Context) {
|
||||
go p.channel(ctx)
|
||||
var server *http.Server
|
||||
endpoint := ""
|
||||
serverIdentity := ""
|
||||
var refreshAt time.Time
|
||||
closeServer := func() {
|
||||
if server != nil {
|
||||
timeout, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
@@ -162,6 +164,7 @@ func (p *Pairing) Run(ctx context.Context) {
|
||||
server = nil
|
||||
}
|
||||
endpoint = ""
|
||||
serverIdentity = ""
|
||||
}
|
||||
defer closeServer()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
@@ -170,29 +173,50 @@ func (p *Pairing) Run(ctx context.Context) {
|
||||
p.mu.Lock()
|
||||
_ = p.expire()
|
||||
desired := ""
|
||||
var recovery *CoreBinding
|
||||
identity := "bootstrap"
|
||||
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation != nil {
|
||||
desired = p.state.Invitation.Endpoint
|
||||
}
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil {
|
||||
for _, address := range p.addresses() {
|
||||
if tailnetAddress(address) {
|
||||
desired = "https://" + address + ":" + PairPort
|
||||
copy := *p.state.Binding
|
||||
recovery = ©
|
||||
identity = copy.BindingID + copy.CoreID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if desired != endpoint {
|
||||
if desired != endpoint || (desired != "" && (identity != serverIdentity || time.Now().After(refreshAt))) {
|
||||
closeServer()
|
||||
if desired != "" {
|
||||
u, _ := url.Parse(desired)
|
||||
cert, e := bootstrapCertificate(p.store.pairingKey(), u.Hostname())
|
||||
config := &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}
|
||||
handler := p.remoteHandler()
|
||||
if recovery != nil {
|
||||
config, e = recoveryTLS(*recovery, p.store.pairingKey(), u.Hostname())
|
||||
handler = p.recoveryHandler()
|
||||
}
|
||||
var listener net.Listener
|
||||
if e == nil {
|
||||
listener, e = net.Listen("tcp4", u.Host)
|
||||
}
|
||||
p.mu.Lock()
|
||||
if e != nil {
|
||||
p.listenError = "Не удалось открыть частное подключение. Проверьте адрес и создайте приглашение повторно."
|
||||
p.listenError = "Не удалось открыть частное подключение. Проверьте доступность выбранной сети."
|
||||
} else {
|
||||
p.listenError = ""
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if e == nil {
|
||||
server = &http.Server{Handler: p.remoteHandler(), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}}
|
||||
server = &http.Server{Handler: handler, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: config}
|
||||
endpoint = desired
|
||||
serverIdentity = identity
|
||||
refreshAt = time.Now().Add(12 * time.Hour)
|
||||
go func(s *http.Server, l net.Listener) { _ = s.Serve(tls.NewListener(l, s.TLSConfig)) }(server, &pairingListener{Listener: listener, slots: make(chan struct{}, 16), done: make(chan struct{})})
|
||||
}
|
||||
}
|
||||
@@ -209,7 +233,7 @@ func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload
|
||||
if e != nil {
|
||||
return nil, 0, e
|
||||
}
|
||||
cacheKey := b.BindingID + digest(b.ClientPEM)
|
||||
cacheKey := b.BindingID + b.Endpoint + digest(b.ClientPEM)
|
||||
client := p.clients[cacheKey]
|
||||
if client == nil {
|
||||
transport := &http.Transport{TLSClientConfig: config, Proxy: nil, MaxConnsPerHost: 1, MaxIdleConnsPerHost: 1, IdleConnTimeout: 15 * time.Second, TLSHandshakeTimeout: 4 * time.Second, DialContext: (&net.Dialer{Timeout: 4 * time.Second}).DialContext}
|
||||
@@ -262,10 +286,10 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
p.mu.Unlock()
|
||||
wanted := make(map[string]bool)
|
||||
if binding != nil {
|
||||
wanted[binding.BindingID+digest(binding.ClientPEM)] = true
|
||||
wanted[binding.BindingID+binding.Endpoint+digest(binding.ClientPEM)] = true
|
||||
}
|
||||
for _, b := range revocations {
|
||||
wanted[b.BindingID+digest(b.ClientPEM)] = true
|
||||
wanted[b.BindingID+b.Endpoint+digest(b.ClientPEM)] = true
|
||||
}
|
||||
for key, c := range p.clients {
|
||||
if !wanted[key] {
|
||||
@@ -276,6 +300,8 @@ 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{}}
|
||||
payload["core_endpoint"] = binding.Endpoint
|
||||
payload["endpoint_revision"] = binding.EndpointRevision
|
||||
if p.Sensors != nil {
|
||||
inv := p.Sensors.Inventory()
|
||||
payload["devices"] = inv["items"]
|
||||
@@ -291,7 +317,7 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
}
|
||||
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 p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID && p.state.Binding.Endpoint == binding.Endpoint && p.state.Binding.EndpointRevision == binding.EndpointRevision {
|
||||
if e == nil && status == 200 {
|
||||
if p.Monitor != nil {
|
||||
p.Monitor.Acknowledge(result["monitor_ack"])
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestUSBEventsAreHintsAndDoNotMultiplyInterfaces(t *testing.T) {
|
||||
func TestSensorEventStreamRequiresLocalSession(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.Sensors, _ = OpenSensors(t.TempDir(), "node_test")
|
||||
isolateSensorHost(t, s.Sensors)
|
||||
if response := call(s, "GET", "/api/devices/events", "", nil); response.Code != 401 {
|
||||
t.Fatal("unauthenticated device stream", response.Code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Model bindings are shipped code, never executables or paths supplied by a
|
||||
// browser. A model owns its driver/profile; a physical device owns its session.
|
||||
type sensorModel struct {
|
||||
ID, Name, Prefix, Kind, Plugin, Version string
|
||||
Vendor, Product, USBName string
|
||||
Socket, PrepareUnit, Report string
|
||||
Actions map[string]bool
|
||||
}
|
||||
|
||||
func actions(names ...string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, name := range names {
|
||||
out[name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var sensorModels = []sensorModel{
|
||||
{ID: "realsense.d455", Name: "RealSense D455", Prefix: "rsd455", Plugin: "missioncore.realsense", Version: "0.6.6",
|
||||
Vendor: "8086", Product: "0b5c", Socket: "/run/mission-core-sensors/driver.sock",
|
||||
PrepareUnit: "mission-core-node-realsense-prepare.service", Report: "/var/lib/mission-core-node-drivers/preparation.json",
|
||||
Actions: actions("prepare", "details", "rename", "verify", "start", "replay", "stop", "option", "offer", "close-peer")},
|
||||
{ID: "xgrids.k1", Name: "XGRIDS K1", Prefix: "k1", Kind: "k1",
|
||||
Actions: actions("details", "rename", "verify", "start", "stop", "option", "offer", "close-peer")},
|
||||
{ID: "insta360.x4", Name: "Insta360 X4", Prefix: "instax4", Kind: "insta360.x4", Plugin: "missioncore.insta360", Version: "0.1.3",
|
||||
// 2e1a:0002 is shared with other Insta360 models. Require the exact
|
||||
// OS product descriptor as well; SDK identity is verified after prepare.
|
||||
Vendor: "2e1a", Product: "0002", USBName: "Insta360 X4", Socket: "/run/mission-core-insta360/driver.sock",
|
||||
PrepareUnit: "mission-core-node-insta360-x4-profile.service", Report: "/var/lib/mission-core-node-profiles/insta360-x4/preparation.json",
|
||||
Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer")},
|
||||
}
|
||||
|
||||
func modelForDevice(id string) *sensorModel {
|
||||
if !sensorID.MatchString(id) {
|
||||
return nil
|
||||
}
|
||||
for i := range sensorModels {
|
||||
if strings.HasPrefix(id, sensorModels[i].Prefix+"_") {
|
||||
return &sensorModels[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentCameraSnapshot(item map[string]any, model *sensorModel) bool {
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
context, _ := snapshot["context"].(map[string]any)
|
||||
device, _ := context["device"].(map[string]any)
|
||||
installed, _ := device["model"].(map[string]any)
|
||||
_, revision := snapshot["revision"].(float64) // Decoded driver JSON.
|
||||
observed, _ := snapshot["observed_at"].(string)
|
||||
_, err := time.Parse(time.RFC3339Nano, observed)
|
||||
return revision && err == nil && installed["plugin_version"] == model.Version && installed["model_id"] == model.ID
|
||||
}
|
||||
|
||||
func modelDeviceID(model *sensorModel, serial string) string {
|
||||
h := sha256.Sum256([]byte(serial))
|
||||
return model.Prefix + "_" + hex.EncodeToString(h[:])[:32]
|
||||
}
|
||||
|
||||
func sensorClient(socket string) *http.Client {
|
||||
return &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
}}}
|
||||
}
|
||||
|
||||
type usbSensor struct {
|
||||
model *sensorModel
|
||||
id, speed, binding string
|
||||
stable bool
|
||||
}
|
||||
|
||||
// Read-only OS discovery works before any vendor runtime has been installed.
|
||||
// An absent or duplicated serial is not enough authority to initialize a unit.
|
||||
func discoverSensors(root string) []usbSensor {
|
||||
paths, _ := filepath.Glob(filepath.Join(root, "*"))
|
||||
items := []usbSensor{}
|
||||
counts := map[string]int{}
|
||||
for _, path := range paths {
|
||||
read := func(name string) string {
|
||||
data, _ := os.ReadFile(filepath.Join(path, name))
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
for i := range sensorModels {
|
||||
model := &sensorModels[i]
|
||||
if model.Vendor == "" || read("idVendor") != model.Vendor || read("idProduct") != model.Product || (model.USBName != "" && read("product") != model.USBName) {
|
||||
continue
|
||||
}
|
||||
serial := read("serial")
|
||||
id := modelDeviceID(model, serial)
|
||||
counts[id]++
|
||||
items = append(items, usbSensor{model: model, id: id, speed: read("speed") + " Мбит/с", binding: filepath.Base(path) + ":" + read("devnum"), stable: serial != ""})
|
||||
}
|
||||
}
|
||||
unique := []usbSensor{}
|
||||
for _, item := range items {
|
||||
if !item.stable || counts[item.id] != 1 {
|
||||
item.stable = false
|
||||
item.id = modelDeviceID(item.model, "provisional:"+item.binding)
|
||||
}
|
||||
unique = append(unique, item)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
type discoverySession struct {
|
||||
binding, session string
|
||||
stable bool
|
||||
}
|
||||
|
||||
func (s *Sensors) reconcileDiscovery(devices []usbSensor) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
live := map[string]discoverySession{}
|
||||
for _, device := range devices {
|
||||
previous := s.discoverySessions[device.id]
|
||||
if previous.binding != device.binding || previous.session == "" {
|
||||
previous = discoverySession{device.binding, "discovery_" + digest(token())[:24] + "_" + device.id, device.stable}
|
||||
}
|
||||
live[device.id] = previous
|
||||
}
|
||||
s.discoverySessions = live
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeUSB(t *testing.T, root, port, serial, product, number string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, port)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]string{"idVendor": "2e1a", "idProduct": "0002", "product": product, "serial": serial, "devnum": number, "speed": "5000"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, key), []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testSensorReply(value any) *http.Response {
|
||||
data, _ := json.Marshal(value)
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(data))), Header: http.Header{}}
|
||||
}
|
||||
|
||||
func isolatedSensors(t *testing.T) *Sensors {
|
||||
t.Helper()
|
||||
s, err := OpenSensors(t.TempDir(), "node_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.usbRoot = t.TempDir()
|
||||
for _, client := range s.clients {
|
||||
client.Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return testSensorReply(map[string]any{"items": []any{}}), nil
|
||||
})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestModelDiscoveryIdentityAndHotplug(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
fakeUSB(t, s.usbRoot, "3-3", "synthetic-b", "Insta360 X4", "3")
|
||||
fakeUSB(t, s.usbRoot, "3-4", "synthetic-other", "Insta360 OneR", "4")
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("wrong models admitted: %d", len(items))
|
||||
}
|
||||
first := items[0].(map[string]any)
|
||||
id := first["id"].(string)
|
||||
if id == items[1].(map[string]any)["id"] || first["prepared"] != false || first["configured"] != false {
|
||||
t.Fatal("instances collapsed or discovery claimed readiness")
|
||||
}
|
||||
session := sensorSessionID(first)
|
||||
if sensorSessionID(s.Inventory()["items"].([]any)[0].(map[string]any)) != session {
|
||||
t.Fatal("refresh changed session")
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(s.usbRoot, "3-2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Inventory()
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "7")
|
||||
again := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
if again["id"] != id || sensorSessionID(again) == session {
|
||||
t.Fatal("replug did not retain identity and renew session")
|
||||
}
|
||||
// D455 IDs use the original unnamespaced serial hash, preserving archives.
|
||||
if got := modelDeviceID(&sensorModels[0], "abc"); got != "rsd455_ba7816bf8f01cfea414140de5dae2223" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if sensorModels[1].Kind != "k1" {
|
||||
t.Fatal("K1 contribution binding changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateSerialCannotAcquireCameraAuthority(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fakeUSB(t, root, "3-2", "duplicate", "Insta360 X4", "2")
|
||||
fakeUSB(t, root, "3-3", "duplicate", "Insta360 X4", "3")
|
||||
items := discoverSensors(root)
|
||||
if len(items) != 2 || items[0].id == items[1].id || items[0].stable || items[1].stable {
|
||||
t.Fatal("ambiguous USB devices disappeared or acquired stable authority")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCameraProfileKeepsRemoteInitializationAvailable(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-legacy", "Insta360 X4", "2")
|
||||
model := &sensorModels[2]
|
||||
id := modelDeviceID(model, "synthetic-legacy")
|
||||
legacy := s.discovery(id, "5000", true)
|
||||
legacy["prepared"] = true
|
||||
snapshot := legacy["snapshot"].(map[string]any)
|
||||
delete(snapshot, "observed_at")
|
||||
delete(snapshot, "revision")
|
||||
s.clients[model.ID].Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return testSensorReply(map[string]any{"items": []any{legacy}}), nil
|
||||
})
|
||||
for _, state := range []string{"idle", "live", "unknown"} {
|
||||
snapshot["acquisition"] = state
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatal("legacy runtime hid or duplicated the USB camera")
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
fresh := item["snapshot"].(map[string]any)
|
||||
if item["prepared"] != false || fresh["observed_at"] == nil || fresh["revision"] == nil || sensorSessionID(item) == "" {
|
||||
t.Fatal("legacy snapshot escaped the discovery fallback")
|
||||
}
|
||||
if (state != "idle") != (item["preparation_safe"] == false) {
|
||||
t.Fatal("legacy acquisition safety was lost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func awaitSensor(t *testing.T, predicate func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if predicate() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("sensor operation timed out")
|
||||
}
|
||||
|
||||
func TestNewCameraUsesReadyProfileWithoutInterruptingAnotherInstance(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-active", "Insta360 X4", "2")
|
||||
fakeUSB(t, s.usbRoot, "3-3", "synthetic-new", "Insta360 X4", "3")
|
||||
active := modelDeviceID(&sensorModels[2], "synthetic-active")
|
||||
newDevice := modelDeviceID(&sensorModels[2], "synthetic-new")
|
||||
var runs atomic.Int32
|
||||
var stopped atomic.Bool
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
s.runPreparation = func(context.Context, string) error {
|
||||
runs.Add(1)
|
||||
return errors.New("must not redeploy a ready shared profile")
|
||||
}
|
||||
s.clients["insta360.x4"].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/inventory" {
|
||||
items := []any{}
|
||||
for _, id := range []string{active, newDevice} {
|
||||
item := s.discovery(id, "5000", true)
|
||||
item["prepared"] = true
|
||||
item["preparation_safe"] = id != active
|
||||
snapshot := item["snapshot"].(map[string]any)
|
||||
snapshot["context"].(map[string]any)["session_id"] = "sdk_" + id
|
||||
if id == active { snapshot["acquisition"] = "streaming" }
|
||||
items = append(items, item)
|
||||
}
|
||||
return testSensorReply(map[string]any{"items": items}), nil
|
||||
}
|
||||
var command SensorCommand
|
||||
_ = json.NewDecoder(r.Body).Decode(&command)
|
||||
if command.Action == "verify" && command.Session.DeviceID == newDevice {
|
||||
<-release
|
||||
} else if command.Action == "preview.stop" && command.Session.DeviceID == active {
|
||||
stopped.Store(true)
|
||||
} else {
|
||||
return nil, errors.New("cross-camera command")
|
||||
}
|
||||
return testSensorReply(map[string]any{"state": "complete", "result": map[string]bool{"ok": true}}), nil
|
||||
})
|
||||
s.Inventory()
|
||||
command := sensorTestCommand()
|
||||
command.Action = "prepare"
|
||||
command.Session = SensorSession{DeviceID: newDevice, SessionID: "sdk_" + newDevice}
|
||||
if _, err := s.Submit(command, true); err != nil { t.Fatal(err) }
|
||||
awaitSensor(t, func() bool { op := s.Get(command.ID); return op.Preparation != nil && op.Preparation.Phase == "verify" })
|
||||
stop := sensorTestCommand()
|
||||
stop.ID = "op_" + strings.Repeat("b", 32)
|
||||
stop.Idempotency = stop.ID
|
||||
stop.Action = "preview.stop"
|
||||
stop.Session = SensorSession{DeviceID: active, SessionID: "sdk_" + active}
|
||||
if _, err := s.Submit(stop, false); err != nil { t.Fatal(err) }
|
||||
awaitSensor(t, func() bool { return stopped.Load() })
|
||||
if runs.Load() != 0 { t.Fatal("new-camera preparation redeployed the shared profile") }
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
awaitSensor(t, func() bool { return s.Get(command.ID).State == "complete" })
|
||||
}
|
||||
|
||||
func TestLocalAndRemotePrepareShareProfileButVerifyEachInstance(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
fakeUSB(t, s.usbRoot, "3-3", "synthetic-b", "Insta360 X4", "3")
|
||||
initial := s.Inventory()["items"].([]any)
|
||||
var installed atomic.Bool
|
||||
var runs atomic.Int32
|
||||
var mu sync.Mutex
|
||||
verified := []string{}
|
||||
release := make(chan struct{})
|
||||
s.runPreparation = func(ctx context.Context, unit string) error {
|
||||
if unit != "mission-core-node-insta360-x4-profile.service" {
|
||||
return errors.New("wrong unit")
|
||||
}
|
||||
runs.Add(1)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-release:
|
||||
}
|
||||
installed.Store(true)
|
||||
return nil
|
||||
}
|
||||
s.clients["insta360.x4"].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/inventory" {
|
||||
items := []any{}
|
||||
if installed.Load() {
|
||||
for _, raw := range initial {
|
||||
old := raw.(map[string]any)
|
||||
id := old["id"].(string)
|
||||
item := s.discovery(id, "5000", true)
|
||||
item["prepared"] = true
|
||||
item["snapshot"].(map[string]any)["context"].(map[string]any)["session_id"] = "sdk_" + id
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return testSensorReply(map[string]any{"items": items}), nil
|
||||
}
|
||||
var c SensorCommand
|
||||
_ = json.NewDecoder(r.Body).Decode(&c)
|
||||
if c.Action != "verify" || c.Session.SessionID != "sdk_"+c.Session.DeviceID {
|
||||
return nil, errors.New("wrong instance session")
|
||||
}
|
||||
mu.Lock()
|
||||
verified = append(verified, c.Session.DeviceID)
|
||||
mu.Unlock()
|
||||
if c.Session.DeviceID == initial[1].(map[string]any)["id"] {
|
||||
return testSensorReply(map[string]any{"state": "error", "error": "no frames"}), nil
|
||||
}
|
||||
return testSensorReply(map[string]any{"state": "complete", "result": map[string]any{"device_id": c.Session.DeviceID}}), nil
|
||||
})
|
||||
// An active camera of another model must not block X4 installation.
|
||||
s.client.Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
item := s.discovery(sensorTestCommand().Session.DeviceID, "5000", true)
|
||||
item["snapshot"].(map[string]any)["acquisition"] = "streaming"
|
||||
return testSensorReply(map[string]any{"items": []any{item}}), nil
|
||||
})
|
||||
commands := []SensorCommand{}
|
||||
for i, raw := range initial {
|
||||
item := raw.(map[string]any)
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.ID = "op_" + strings.Repeat(string(rune('a'+i)), 32)
|
||||
c.Idempotency = c.ID
|
||||
c.Session = SensorSession{DeviceID: item["id"].(string), SessionID: sensorSessionID(item)}
|
||||
commands = append(commands, c)
|
||||
if _, err := s.Submit(c, i == 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
awaitSensor(t, func() bool {
|
||||
return s.Get(commands[0].ID).Preparation != nil && s.Get(commands[1].ID).Preparation != nil
|
||||
})
|
||||
close(release)
|
||||
awaitSensor(t, func() bool {
|
||||
return s.Get(commands[0].ID).State != "running" && s.Get(commands[1].ID).State != "running"
|
||||
})
|
||||
if runs.Load() != 1 {
|
||||
t.Fatalf("deployed %d times", runs.Load())
|
||||
}
|
||||
if s.Get(commands[0].ID).State != "complete" || s.Get(commands[1].ID).State != "error" {
|
||||
t.Fatal("instance outcomes mixed")
|
||||
}
|
||||
s.mu.Lock()
|
||||
a, b := s.initialized[commands[0].Session.DeviceID], s.initialized[commands[1].Session.DeviceID]
|
||||
s.mu.Unlock()
|
||||
if !a || b {
|
||||
t.Fatal("success initialized a different camera")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(verified) != 2 || verified[0] == verified[1] {
|
||||
t.Fatal("verification routed to same instance")
|
||||
}
|
||||
if len(s.RemoteResults()) != 1 {
|
||||
t.Fatal("remote result was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalePrepareAndCrossModelActionsCannotMutate(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
item := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
var calls atomic.Int32
|
||||
s.runPreparation = func(context.Context, string) error { calls.Add(1); return nil }
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.Session.DeviceID = item["id"].(string)
|
||||
if _, err := s.Submit(c, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return s.Get(c.ID).State != "running" })
|
||||
if calls.Load() != 0 || s.Get(c.ID).State != "error" {
|
||||
t.Fatal("stale session initialized a camera")
|
||||
}
|
||||
c = sensorTestCommand()
|
||||
c.Action = "record.start"
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("X4 recording command admitted for D455")
|
||||
}
|
||||
c.Session.DeviceID = item["id"].(string)
|
||||
c.Action = "start"
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("legacy ambiguous start admitted for X4")
|
||||
}
|
||||
c.Action = "prepare"
|
||||
c.Parameters = map[string]any{"unit": "unrelated.service"}
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("caller chose privileged profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingWithoutPreviewBlocksModelPreparation(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
item := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
// The camera's SD recording is independent from acquisition/preview.
|
||||
item["prepared"] = true
|
||||
item["preparation_safe"] = false
|
||||
s.clients["insta360.x4"].Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return testSensorReply(map[string]any{"items": []any{item}}), nil
|
||||
})
|
||||
var calls atomic.Int32
|
||||
s.runPreparation = func(context.Context, string) error { calls.Add(1); return nil }
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.Session = SensorSession{DeviceID: item["id"].(string), SessionID: sensorSessionID(item)}
|
||||
if _, err := s.Submit(c, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return s.Get(c.ID).State != "running" })
|
||||
if s.Get(c.ID).State != "error" || calls.Load() != 0 {
|
||||
t.Fatal("preparation changed a model with active camera recording")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errPreparationUncertain = errors.New("Результат подготовки неизвестен. Обновите состояние устройства.")
|
||||
|
||||
type preparationStep struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
State string `json:"state"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type sensorPreparation struct {
|
||||
OperationID string `json:"operation_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
StartedAt float64 `json:"started_at"`
|
||||
ProfileStartedAt float64 `json:"profile_started_at"`
|
||||
State string `json:"state"`
|
||||
Phase string `json:"phase"`
|
||||
Steps []preparationStep `json:"steps"`
|
||||
}
|
||||
|
||||
type profilePreparation struct {
|
||||
done chan struct{}
|
||||
started float64
|
||||
err error
|
||||
users int
|
||||
finished bool
|
||||
}
|
||||
|
||||
func runModelPreparation(ctx context.Context, unit string) error {
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", unit)
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"}
|
||||
if cmd.Run() != nil {
|
||||
if ctx.Err() != nil {
|
||||
return errPreparationUncertain
|
||||
}
|
||||
return errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sensorSessionID(item map[string]any) string {
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
context, _ := snapshot["context"].(map[string]any)
|
||||
session, _ := context["session_id"].(string)
|
||||
return session
|
||||
}
|
||||
|
||||
func (s *Sensors) preparationPhase(c SensorCommand, model *sensorModel, job *profilePreparation, phase, state string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
op := s.operations[c.ID]
|
||||
if op == nil {
|
||||
return errors.New("Операция подготовки не найдена.")
|
||||
}
|
||||
started, _ := time.Parse(time.RFC3339Nano, c.Requested)
|
||||
deploy, verify := "running", "pending"
|
||||
if phase == "verify" {
|
||||
deploy, verify = "complete", "running"
|
||||
}
|
||||
if state != "running" {
|
||||
if phase == "profile" {
|
||||
deploy, verify = state, "blocked"
|
||||
} else {
|
||||
verify = state
|
||||
}
|
||||
}
|
||||
op.Preparation = &sensorPreparation{OperationID: c.ID, DeviceID: c.Session.DeviceID, ModelID: model.ID,
|
||||
StartedAt: float64(started.UnixMilli()) / 1000, ProfileStartedAt: job.started, State: state, Phase: phase,
|
||||
Steps: []preparationStep{{ID: "profile", Label: "Подготовка драйвера", State: deploy}, {ID: "verify", Label: "Проверка выбранной камеры", State: verify}}}
|
||||
op.Updated = time.Now().Unix()
|
||||
err := s.write(c.ID+".json", op)
|
||||
s.events.notify()
|
||||
return err
|
||||
}
|
||||
|
||||
// A concurrent prepare for another instance of this model joins the same
|
||||
// deployment. Each command subsequently verifies only its own physical unit.
|
||||
func (s *Sensors) profileJob(model *sensorModel) *profilePreparation {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if job := s.preparing[model.ID]; job != nil {
|
||||
job.users++
|
||||
return job
|
||||
}
|
||||
job := &profilePreparation{done: make(chan struct{}), started: float64(time.Now().UnixMilli()) / 1000, users: 1}
|
||||
s.preparing[model.ID] = job
|
||||
go func() {
|
||||
// Only this profile's devices can be affected by its service activation.
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
id, _ := item["id"].(string)
|
||||
if modelForDevice(id) != model {
|
||||
continue
|
||||
}
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
if state := snapshot["acquisition"]; (state != "idle" && state != "failed") || item["preparation_safe"] == false {
|
||||
job.err = errors.New("Остановите захват устройств этой модели перед подготовкой драйвера.")
|
||||
break
|
||||
}
|
||||
}
|
||||
if job.err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
|
||||
job.err = s.runPreparation(ctx, model.PrepareUnit)
|
||||
cancel()
|
||||
}
|
||||
s.mu.Lock()
|
||||
job.finished = true
|
||||
if job.users == 0 {
|
||||
delete(s.preparing, model.ID)
|
||||
}
|
||||
close(job.done)
|
||||
s.mu.Unlock()
|
||||
s.events.notify()
|
||||
}()
|
||||
return job
|
||||
}
|
||||
|
||||
func reusableCameraProfile(item map[string]any, model *sensorModel) bool {
|
||||
if item["configured"] == true || item["prepared"] != true || item["preparation_safe"] == false {
|
||||
return false
|
||||
}
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
if snapshot["acquisition"] != "idle" {
|
||||
return false
|
||||
}
|
||||
context, _ := snapshot["context"].(map[string]any)
|
||||
device, _ := context["device"].(map[string]any)
|
||||
installed, _ := device["model"].(map[string]any)
|
||||
return installed["plugin_version"] == model.Version && installed["model_id"] == model.ID
|
||||
}
|
||||
|
||||
func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any, err error) {
|
||||
model := modelForDevice(c.Session.DeviceID)
|
||||
if model == nil || model.PrepareUnit == "" {
|
||||
return nil, errors.New("Подготовка этой модели не поддерживается.")
|
||||
}
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
s.mu.Lock()
|
||||
initialBinding := s.discoverySessions[c.Session.DeviceID].binding
|
||||
s.mu.Unlock()
|
||||
// A newly attached camera can use the already running admitted profile.
|
||||
// Verification belongs to this camera and need not stop other instances.
|
||||
reuse := reusableCameraProfile(selected, model)
|
||||
s.mu.Lock()
|
||||
if active := s.preparing[model.ID]; active != nil && !active.finished {
|
||||
reuse = false
|
||||
}
|
||||
s.mu.Unlock()
|
||||
var job *profilePreparation
|
||||
if reuse {
|
||||
job = &profilePreparation{done: make(chan struct{}), started: float64(time.Now().UnixMilli()) / 1000, users: 1, finished: true}
|
||||
close(job.done)
|
||||
} else {
|
||||
job = s.profileJob(model)
|
||||
}
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
job.users--
|
||||
if job.users == 0 && job.finished && s.preparing[model.ID] == job {
|
||||
delete(s.preparing, model.ID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
phase := "profile"
|
||||
defer func() {
|
||||
state := "complete"
|
||||
if err != nil {
|
||||
state = "error"
|
||||
}
|
||||
if errors.Is(err, errPreparationUncertain) {
|
||||
state = "unknown"
|
||||
}
|
||||
if e := s.preparationPhase(c, model, job, phase, state); e != nil {
|
||||
err = errPreparationUncertain
|
||||
}
|
||||
}()
|
||||
if s.preparationPhase(c, model, job, phase, "running") != nil {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, errPreparationUncertain
|
||||
case <-job.done:
|
||||
if job.err != nil {
|
||||
return nil, job.err
|
||||
}
|
||||
}
|
||||
phase = "verify"
|
||||
if s.preparationPhase(c, model, job, phase, "running") != nil {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
// X4 bounds vendor Open at 40 seconds; allow that startup window without
|
||||
// treating an active unit as a verified device. The command deadline still
|
||||
// bounds the observer independently of the root preparation transaction.
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
if ctx.Err() != nil {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
inventory := s.Inventory()
|
||||
s.mu.Lock()
|
||||
binding := s.discoverySessions[c.Session.DeviceID].binding
|
||||
s.mu.Unlock()
|
||||
if initialBinding != "" && binding != initialBinding {
|
||||
return nil, errors.New("Камера переподключена во время подготовки. Повторите проверку устройства.")
|
||||
}
|
||||
for _, raw := range inventory["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["id"] != c.Session.DeviceID || item["online"] != true || item["prepared"] != true {
|
||||
continue
|
||||
}
|
||||
verify := c
|
||||
verify.Action = "verify"
|
||||
verify.Session.SessionID = sensorSessionID(item)
|
||||
if verify.Session.SessionID == "" {
|
||||
return nil, errors.New("Драйвер не подтвердил сеанс камеры.")
|
||||
}
|
||||
response, e := s.modelDriver(ctx, model, "/operation", verify)
|
||||
if e != nil || response["state"] == "unknown" {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
if response["state"] != "complete" {
|
||||
message, _ := response["error"].(string)
|
||||
if message == "" {
|
||||
message = "Не удалось проверить изображение выбранной камеры."
|
||||
}
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
return response["result"], nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, errPreparationUncertain
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
|
||||
}
|
||||
|
||||
// Project only the matching model run. A previous success or another model's
|
||||
// report cannot become the progress of the selected device's verification.
|
||||
func preparationView(value *sensorPreparation) *sensorPreparation {
|
||||
copy := *value
|
||||
model := modelForDevice(value.DeviceID)
|
||||
if model == nil || len(value.Steps) == 0 {
|
||||
return ©
|
||||
}
|
||||
data, err := os.ReadFile(model.Report)
|
||||
if err != nil || len(data) > 32768 {
|
||||
return ©
|
||||
}
|
||||
var report struct {
|
||||
ModelID string `json:"model_id"`
|
||||
StartedAt float64 `json:"started_at"`
|
||||
Steps []preparationStep `json:"steps"`
|
||||
}
|
||||
if json.Unmarshal(data, &report) != nil || report.ModelID != value.ModelID || report.StartedAt < value.ProfileStartedAt || len(report.Steps) == 0 || len(report.Steps) > 32 {
|
||||
return ©
|
||||
}
|
||||
copy.Steps = append(report.Steps, value.Steps[len(value.Steps)-1])
|
||||
return ©
|
||||
}
|
||||
@@ -3,15 +3,11 @@ package node
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -37,28 +33,34 @@ type SensorCommand struct {
|
||||
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"`
|
||||
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"`
|
||||
Preparation *sensorPreparation `json:"preparation,omitempty"`
|
||||
}
|
||||
type Sensors struct {
|
||||
events sensorEvents
|
||||
mu sync.Mutex
|
||||
prepareMu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
instance string
|
||||
client *http.Client
|
||||
NetworkDevices *DeviceEnrollment
|
||||
operations map[string]*SensorOperation
|
||||
names map[string]string
|
||||
initialized map[string]bool
|
||||
events sensorEvents
|
||||
mu sync.Mutex
|
||||
inventoryMu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
instance string
|
||||
client *http.Client
|
||||
clients map[string]*http.Client
|
||||
usbRoot string
|
||||
discoverySessions map[string]discoverySession
|
||||
preparing map[string]*profilePreparation
|
||||
runPreparation func(context.Context, string) error
|
||||
NetworkDevices *DeviceEnrollment
|
||||
operations map[string]*SensorOperation
|
||||
names map[string]string
|
||||
initialized map[string]bool
|
||||
}
|
||||
|
||||
var sensorID = regexp.MustCompile(`^(rsd455|k1)_[0-9a-f]{32}$`)
|
||||
var sensorID = regexp.MustCompile(`^[a-z][a-z0-9]{1,31}_[0-9a-f]{32}$`)
|
||||
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
|
||||
|
||||
func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
@@ -66,10 +68,13 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
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{}, initialized: map[string]bool{}}
|
||||
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")
|
||||
}}}
|
||||
s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}, initialized: map[string]bool{}, clients: map[string]*http.Client{}, usbRoot: "/sys/bus/usb/devices", discoverySessions: map[string]discoverySession{}, preparing: map[string]*profilePreparation{}, runPreparation: runModelPreparation}
|
||||
for _, model := range sensorModels {
|
||||
if model.Socket != "" {
|
||||
s.clients[model.ID] = sensorClient(model.Socket)
|
||||
}
|
||||
}
|
||||
s.client = s.clients["realsense.d455"]
|
||||
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
|
||||
for _, p := range files {
|
||||
data, e := os.ReadFile(p)
|
||||
@@ -83,11 +88,17 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
if v.State == "running" {
|
||||
v.State = "unknown"
|
||||
v.Error = "Результат операции неизвестен после перезапуска. Проверьте состояние устройства."
|
||||
if v.Preparation != nil {
|
||||
v.Preparation.State = "unknown"
|
||||
}
|
||||
}
|
||||
s.operations[v.Command.ID] = &v
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "names.json"))
|
||||
_ = json.Unmarshal(data, &s.names)
|
||||
if s.names == nil {
|
||||
s.names = map[string]string{}
|
||||
}
|
||||
data, _ = os.ReadFile(filepath.Join(dir, "initialized.json"))
|
||||
_ = json.Unmarshal(data, &s.initialized)
|
||||
if s.initialized == nil {
|
||||
@@ -133,6 +144,25 @@ func (s *Sensors) write(name string, value any) error {
|
||||
return d.Sync()
|
||||
}
|
||||
func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
return s.modelDriver(context.Background(), &sensorModels[0], path, body)
|
||||
}
|
||||
func (s *Sensors) modelDriver(ctx context.Context, model *sensorModel, path string, body any) (map[string]any, error) {
|
||||
if model.Prefix == "k1" {
|
||||
if s.NetworkDevices == nil {
|
||||
return nil, errors.New("Служба устройства недоступна.")
|
||||
}
|
||||
if path == "/operation" {
|
||||
path = "/sensor-operation"
|
||||
}
|
||||
return s.NetworkDevices.call(ctx, path, body)
|
||||
}
|
||||
client := s.clients[model.ID]
|
||||
if model.ID == "realsense.d455" {
|
||||
client = s.client
|
||||
}
|
||||
if client == nil {
|
||||
return nil, errors.New("Интеграция устройства не установлена.")
|
||||
}
|
||||
method := "GET"
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
@@ -143,7 +173,7 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, e := http.NewRequest(method, "http://driver"+path, reader)
|
||||
req, e := http.NewRequestWithContext(ctx, method, "http://driver"+path, reader)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -151,13 +181,14 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, e := s.client.Do(req)
|
||||
response, e := 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 {
|
||||
data, readErr := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024+1))
|
||||
if readErr != nil || len(data) > 2*1024*1024 || json.Unmarshal(data, &result) != nil {
|
||||
return nil, errors.New("Не удалось прочитать результат драйвера.")
|
||||
}
|
||||
if response.StatusCode != 200 {
|
||||
@@ -167,78 +198,69 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
return result, nil
|
||||
}
|
||||
func (s *Sensors) Inventory() map[string]any {
|
||||
// Concurrent local/remote refreshes must observe the same hotplug generation.
|
||||
s.inventoryMu.Lock()
|
||||
defer s.inventoryMu.Unlock()
|
||||
usb := discoverSensors(s.usbRoot)
|
||||
s.reconcileDiscovery(usb)
|
||||
items := []any{}
|
||||
seen := map[string]bool{}
|
||||
if s.NetworkDevices != nil {
|
||||
unsafePreparation := map[string]bool{}
|
||||
for idx := range sensorModels {
|
||||
model := &sensorModels[idx]
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
result, e := s.NetworkDevices.call(ctx, "/inventory", nil)
|
||||
result, err := s.modelDriver(ctx, model, "/inventory", nil)
|
||||
cancel()
|
||||
if e == nil {
|
||||
if found, ok := result["items"].([]any); ok {
|
||||
for _, raw := range found {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
if !strings.HasPrefix(id, "k1_") || !sensorID.MatchString(id) {
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
if name := s.names[id]; name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
s.mu.Unlock()
|
||||
seen[id] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
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()
|
||||
found, _ := result["items"].([]any)
|
||||
for _, raw := range found {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
if modelForDevice(id) != model || seen[id] {
|
||||
continue
|
||||
}
|
||||
if model.ID == "insta360.x4" && !currentCameraSnapshot(item, model) {
|
||||
// Older installed profiles must not break the paired inventory.
|
||||
// USB discovery still exposes their initialization action, while
|
||||
// preserving a legacy runtime's refusal to interrupt acquisition.
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
unsafePreparation[id] = item["preparation_safe"] == false || snapshot["acquisition"] != "idle"
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
if model.PrepareUnit != "" {
|
||||
item["configured"] = s.initialized[id]
|
||||
if n := s.names[id]; n != "" {
|
||||
item["name"] = n
|
||||
}
|
||||
s.mu.Unlock()
|
||||
items = append(items, item)
|
||||
}
|
||||
if name := s.names[id]; name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if model.Kind != "" {
|
||||
item["kind"] = model.Kind
|
||||
}
|
||||
seen[id] = true
|
||||
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))
|
||||
for _, device := range usb {
|
||||
if !seen[device.id] {
|
||||
seen[device.id] = true
|
||||
item := s.discovery(device.id, device.speed, true)
|
||||
if unsafePreparation[device.id] {
|
||||
item["preparation_safe"] = false
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
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
|
||||
}
|
||||
seen[id] = true
|
||||
items = append(items, s.discovery(id, read("speed")+" Мбит/с", true))
|
||||
}
|
||||
s.mu.Lock()
|
||||
configured := []string{}
|
||||
for id, ready := range s.initialized {
|
||||
if ready && sensorID.MatchString(id) && !seen[id] {
|
||||
if model := modelForDevice(id); ready && model != nil && model.PrepareUnit != "" && !seen[id] {
|
||||
configured = append(configured, id)
|
||||
}
|
||||
}
|
||||
@@ -246,28 +268,41 @@ func (s *Sensors) Inventory() map[string]any {
|
||||
for _, id := range configured {
|
||||
items = append(items, s.discovery(id, "—", false))
|
||||
}
|
||||
|
||||
var preparation any
|
||||
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
|
||||
if data, err := os.ReadFile(sensorModels[0].Report); err == nil && len(data) < 32768 {
|
||||
_ = json.Unmarshal(data, &preparation)
|
||||
}
|
||||
s.mu.Lock()
|
||||
operations := []any{}
|
||||
preparations := []*sensorPreparation{}
|
||||
for _, v := range s.operations {
|
||||
if time.Now().Unix()-v.Updated < 600 {
|
||||
if time.Now().Unix()-v.Updated < 600 || v.State == "running" {
|
||||
operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "requested_at": v.Command.Requested, "state": v.State, "error": v.Error})
|
||||
if v.Preparation != nil {
|
||||
copy := *v.Preparation
|
||||
preparations = append(preparations, ©)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
|
||||
for i, value := range preparations {
|
||||
preparations[i] = preparationView(value)
|
||||
}
|
||||
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "preparations": preparations, "operations": operations}
|
||||
}
|
||||
func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
model := modelForDevice(id)
|
||||
s.mu.Lock()
|
||||
name, configured := s.names[id], s.initialized[id]
|
||||
identity, identityPresent := s.discoverySessions[id]
|
||||
session := s.discoverySessions[id].session
|
||||
if session == "" {
|
||||
session = s.instance + "_" + id
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if name == "" {
|
||||
name = "RealSense D455"
|
||||
name = model.Name
|
||||
}
|
||||
connectivity, enrollment := "offline", "empty"
|
||||
if online {
|
||||
@@ -276,24 +311,30 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
if configured {
|
||||
enrollment = "enrolled"
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": "RealSense D455", "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.6", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
stability, basis := "stable", "hardware-identifier"
|
||||
initializable := !identityPresent || identity.stable
|
||||
if !initializable {
|
||||
stability, basis = "provisional", "transport-local"
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": model.Name, "kind": model.Kind, "initializable": initializable, "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": session, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": model.Plugin, "plugin_version": model.Version, "model_id": model.ID}, "stability": stability, "basis": basis}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
"revision": 0, "enrollment": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
|
||||
}
|
||||
|
||||
func sensorViewAction(action string) bool {
|
||||
return action == "details" || action == "offer" || action == "close-peer"
|
||||
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list"
|
||||
}
|
||||
|
||||
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||
if strings.HasPrefix(c.Session.DeviceID, "k1_") && (c.Action == "prepare" || c.Action == "replay") {
|
||||
return nil, errors.New("Эта операция не поддерживается K1.")
|
||||
}
|
||||
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 {
|
||||
model := modelForDevice(c.Session.DeviceID)
|
||||
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || model == nil || c.Session.SessionID == "" || len(c.Session.SessionID) > 192 {
|
||||
return nil, errors.New("Некорректная команда устройства.")
|
||||
}
|
||||
if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "replay": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] {
|
||||
return nil, errors.New("Операция не поддерживается.")
|
||||
if !model.Actions[c.Action] {
|
||||
return nil, errors.New("Операция не поддерживается этой моделью.")
|
||||
}
|
||||
if c.Action == "prepare" && len(c.Parameters) != 0 {
|
||||
return nil, errors.New("Подготовка использует встроенный профиль устройства.")
|
||||
}
|
||||
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
requested, e2 := time.Parse(time.RFC3339Nano, c.Requested)
|
||||
@@ -315,7 +356,7 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
for _, v := range s.operations {
|
||||
if v.State == "running" && v.Command.Action == "prepare" {
|
||||
if v.State == "running" && v.Command.Action == "prepare" && (v.Preparation == nil || v.Preparation.Phase == "profile") && c.Action != "prepare" && !sensorViewAction(c.Action) && modelForDevice(v.Command.Session.DeviceID) == model {
|
||||
return nil, errors.New("Подготовка модели ещё выполняется.")
|
||||
}
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
@@ -357,10 +398,20 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if item == nil {
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
if !deadline.After(time.Now()) {
|
||||
err = errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
} else if item == nil {
|
||||
err = errors.New("Камера не обнаружена. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["online"] != true {
|
||||
err = errors.New("Камера отключена. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["initializable"] == false {
|
||||
err = errors.New("Не удалось однозначно определить камеру. Проверьте её идентификатор и подключение.")
|
||||
} else if (c.Action == "prepare" || c.Action == "rename") && sensorSessionID(item) != c.Session.SessionID {
|
||||
err = errors.New("Сеанс устройства изменился. Обновите сведения.")
|
||||
} else if c.Action == "prepare" {
|
||||
result, err = s.prepare(c)
|
||||
result, err = s.prepare(c, item)
|
||||
uncertain = errors.Is(err, errPreparationUncertain)
|
||||
} 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") {
|
||||
@@ -374,14 +425,10 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
}
|
||||
} else {
|
||||
var v map[string]any
|
||||
if strings.HasPrefix(c.Session.DeviceID, "k1_") && s.NetworkDevices != nil {
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
v, err = s.NetworkDevices.call(ctx, "/sensor-operation", c)
|
||||
cancel()
|
||||
} else {
|
||||
v, err = s.driver("/operation", c)
|
||||
}
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
v, err = s.modelDriver(ctx, modelForDevice(c.Session.DeviceID), "/operation", c)
|
||||
cancel()
|
||||
uncertain = err != nil || v["state"] == "unknown"
|
||||
if err == nil {
|
||||
if v["state"] == "complete" {
|
||||
@@ -418,48 +465,6 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
v.Error = "Не удалось сохранить результат операции. Обновите состояние устройства."
|
||||
}
|
||||
}
|
||||
func (s *Sensors) prepare(c SensorCommand) (any, error) {
|
||||
s.prepareMu.Lock()
|
||||
defer s.prepareMu.Unlock()
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
snap := item["snapshot"].(map[string]any)
|
||||
if state := snap["acquisition"]; state != "idle" && state != "failed" {
|
||||
return nil, errors.New("Остановите захват камер перед подготовкой модели.")
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
|
||||
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()
|
||||
|
||||
@@ -9,6 +9,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Synthetic tests own both USB discovery and every model transport.
|
||||
func isolateSensorHost(t *testing.T, s *Sensors) {
|
||||
t.Helper()
|
||||
s.usbRoot = t.TempDir()
|
||||
for _, client := range s.clients {
|
||||
client.Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"items":[]}`)), Header: http.Header{}}, nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sensorTestCommand() SensorCommand {
|
||||
now := time.Now()
|
||||
id := "op_01234567890123456789012345678901"
|
||||
@@ -16,6 +27,7 @@ func sensorTestCommand() SensorCommand {
|
||||
}
|
||||
func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T) {
|
||||
s, e := OpenSensors(t.TempDir(), "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
@@ -39,12 +51,16 @@ func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T) {
|
||||
func TestSensorUncertainCrashDoesNotReplay(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, _ := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
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")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
@@ -65,6 +81,7 @@ func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
|
||||
for _, response := range []string{`{"state":"unknown","error":"uncertain"}`, "transport-failure"} {
|
||||
t.Run(response, func(t *testing.T) {
|
||||
s, _ := OpenSensors(t.TempDir(), "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
c := sensorTestCommand()
|
||||
s.operations[c.ID] = &SensorOperation{Command: c, State: "running"}
|
||||
s.client = &http.Client{Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
@@ -88,12 +105,16 @@ func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
|
||||
func TestSensorConfiguredIdentitySurvivesRestartAndDisconnect(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, _ := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
c := sensorTestCommand()
|
||||
s.initialized[c.Session.DeviceID] = true
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user