diff --git a/apps/control-station/test/insta360Ui.test.mjs b/apps/control-station/test/insta360Ui.test.mjs index d4e8bd1..c05a011 100644 --- a/apps/control-station/test/insta360Ui.test.mjs +++ b/apps/control-station/test/insta360Ui.test.mjs @@ -5,7 +5,7 @@ import ts from 'typescript'; const source=readFileSync(new URL('../../../plugins/insta360-x4/frontend/src/model.ts',import.meta.url),'utf8'); const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText; -const {cameraLabel,cameraStatus,resolutionLabel}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64')); +const {cameraLabel,cameraStatus,resolutionLabel,cameraRowActions}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64')); const camera={id:'instax4_synthetic_a',online:true,configured:true,prepared:true,snapshot:{enrollment:'enrolled'},camera_status:{connected:true,recording:0,preview:0,function_mode:7}}; test('SD recording remains visible after preview closes, independently for each camera',()=>{ @@ -27,3 +27,17 @@ test('installed model support does not enroll a new physical camera',()=>{ test('SDK resolution names are presented with their actual dimensions and frame rate',()=>{ assert.equal(resolutionLabel('3840_1920_30'),'3840 × 1920 · 30 кадр/с'); }); + +test('manual wake is available for an enrolled offline camera with automatic recovery disabled',()=>{ + const offline={...camera,online:false,camera_status:{recovery:{supported:true,enabled:false,wake_available:true}}}; + assert.equal(cameraRowActions(offline)[0].actionId,'power.wake'); + assert.equal(cameraRowActions(offline)[0].disabled,false); + assert.deepEqual(cameraRowActions({...offline,online:true}),[]); + assert.equal(cameraRowActions({...offline,configured:false})[0].disabled,true); + assert.equal(cameraRowActions({...offline,prepared:false})[0].disabled,true); + assert.equal(offline.camera_status.recovery.enabled,false); +}); +test('old drivers and unknown wake identities never offer an active power command',()=>{ + assert.deepEqual(cameraRowActions(camera),[]); + assert.equal(cameraRowActions({...camera,online:false,camera_status:{recovery:{supported:true}}})[0].disabled,true); +}); diff --git a/apps/node-agent/internal/node/sensor_models.go b/apps/node-agent/internal/node/sensor_models.go index 5a35e5a..4f6ece5 100644 --- a/apps/node-agent/internal/node/sensor_models.go +++ b/apps/node-agent/internal/node/sensor_models.go @@ -19,6 +19,7 @@ type sensorModel struct { Vendor, Product, USBName string Socket, PrepareUnit, Report string Actions map[string]bool + ActionTimeouts map[string]time.Duration } func actions(names ...string) map[string]bool { @@ -41,7 +42,8 @@ var sensorModels = []sensorModel{ // 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", "recovery.configure")}, + ActionTimeouts: map[string]time.Duration{"power.wake": 55 * time.Second}, + 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", "recovery.configure", "power.wake")}, } func modelForDevice(id string) *sensorModel { diff --git a/apps/node-agent/internal/node/sensor_models_test.go b/apps/node-agent/internal/node/sensor_models_test.go index 98ea5a5..167237e 100644 --- a/apps/node-agent/internal/node/sensor_models_test.go +++ b/apps/node-agent/internal/node/sensor_models_test.go @@ -48,6 +48,40 @@ func isolatedSensors(t *testing.T) *Sensors { return s } +func TestManualWakeDeadlineReachesDriverWithoutChangingInventoryTimeout(t *testing.T) { + s := isolatedSensors(t) + model := &sensorModels[2] + var remaining time.Duration + s.clients[model.ID].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) { + deadline, ok := r.Context().Deadline() + if !ok { + t.Fatal("driver request is unbounded") + } + remaining = time.Until(deadline) + return testSensorReply(map[string]any{"state": "complete"}), nil + }) + for _, action := range []string{"power.wake", "details"} { + if _, err := s.modelDriver(context.Background(), model, "/operation", SensorCommand{Action: action}); err != nil { + t.Fatal(err) + } + want := 25 * time.Second + if action == "power.wake" { + want = 55 * time.Second + } + if remaining > want || remaining < want-time.Second { + t.Fatalf("%s was cut off at %s", action, remaining) + } + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if _, err := s.modelDriver(ctx, model, "/operation", SensorCommand{Action: "power.wake"}); err != nil { + t.Fatal(err) + } + if remaining > time.Second || s.clients[model.ID].Timeout != 25*time.Second { + t.Fatal("action extended the caller deadline or changed the shared client") + } +} + func TestModelDiscoveryIdentityAndHotplug(t *testing.T) { s := isolatedSensors(t) fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2") @@ -160,7 +194,9 @@ func TestNewCameraUsesReadyProfileWithoutInterruptingAnotherInstance(t *testing. 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" } + if id == active { + snapshot["acquisition"] = "streaming" + } items = append(items, item) } return testSensorReply(map[string]any{"items": items}), nil @@ -180,16 +216,22 @@ func TestNewCameraUsesReadyProfileWithoutInterruptingAnotherInstance(t *testing. command := sensorTestCommand() command.Action = "prepare" command.Session = SensorSession{DeviceID: newDevice, SessionID: "sdk_" + newDevice} - if _, err := s.Submit(command, true); err != nil { t.Fatal(err) } + 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) } + 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") } + 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" }) } diff --git a/apps/node-agent/internal/node/sensors.go b/apps/node-agent/internal/node/sensors.go index 4869563..d54e56e 100644 --- a/apps/node-agent/internal/node/sensors.go +++ b/apps/node-agent/internal/node/sensors.go @@ -163,6 +163,15 @@ func (s *Sensors) modelDriver(ctx context.Context, model *sensorModel, path stri if client == nil { return nil, errors.New("Интеграция устройства не установлена.") } + if command, ok := body.(SensorCommand); ok && path == "/operation" { + if timeout := model.ActionTimeouts[command.Action]; timeout > 0 { + // A model may need longer to confirm an action. Preserve the shared + // inventory client and the request's earlier context deadline. + bounded := *client + bounded.Timeout = timeout + client = &bounded + } + } method := "GET" var reader io.Reader if body != nil { diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index 378d681..54cccfc 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -11,8 +11,8 @@ import sys ROOT = Path(__file__).resolve().parents[1] -BINARY_VERSION = "0.8.20" -VERSION = "0.8.20" +BINARY_VERSION = "0.8.21" +VERSION = "0.8.21-3" sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging")) from debian import package diff --git a/apps/node-agent/packaging/insta360-profile.json b/apps/node-agent/packaging/insta360-profile.json index 93af59d..d93ba04 100644 --- a/apps/node-agent/packaging/insta360-profile.json +++ b/apps/node-agent/packaging/insta360-profile.json @@ -1,8 +1,8 @@ { "schema": "missioncore.node.bundled-model/v1", "model_id": "insta360.x4", - "version": "0.1.3-7", - "revision": "45e14de38a278914eedf5cbb", - "bytes": 58210794, - "sha256": "96ce6b52fa72d79ec6f8b0d13bc2af53afda61077b40a3358a3d145295a62d19" + "version": "0.1.3-9", + "revision": "0b1085386d53e65760a9610f", + "bytes": 58211732, + "sha256": "eb6331680fbe8cac56eb745c31d7c37618a9dff67bc2a190681a384813cdc709" } diff --git a/apps/node-agent/packaging/install_owner_release.py b/apps/node-agent/packaging/install_owner_release.py index 38d8dfb..386ad8b 100644 --- a/apps/node-agent/packaging/install_owner_release.py +++ b/apps/node-agent/packaging/install_owner_release.py @@ -121,6 +121,68 @@ def finish_legacy_monitor_pager(): raise RuntimeError("Старая транзакция ещё не завершилась; она не прерывалась.") +def update_installed_x4(run): + """Upgrade an existing model through its fixed profile, even with camera off. + + Bare Ubuntu still uses the application's first preparation. No SDK check or + wake is implied by updating installed package bytes. + """ + result = subprocess.run( + ["/usr/bin/dpkg-query", "-W", "-f", "${Status}", "mission-core-insta360-x4"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode or result.stdout.strip() != "install ok installed": + return {"state": "not-installed"} + profile = json.loads( + Path("/usr/share/mission-core-node/profiles/insta360-x4/profile.json").read_text() + ) + installed = run( + "x4-installed-before", + [ + "/usr/bin/dpkg-query", + "-W", + "-f", + "${Version}", + "mission-core-insta360-x4", + ], + ) + if installed == profile["version"]: + return {"state": "current", "version": installed} + # The shipped profile verifies root-owned bytes, lifecycle quiescence and + # dependency closure. It never requires a connected camera for package upgrade. + run( + "x4-bundle-update", + [ + "/usr/bin/systemctl", + "start", + "mission-core-node-insta360-x4-profile.service", + ], + timeout=None, + ) + after = run( + "x4-installed-after", + [ + "/usr/bin/dpkg-query", + "-W", + "-f", + "${Version}", + "mission-core-insta360-x4", + ], + ) + state = json.loads( + Path("/var/lib/mission-core-node-profiles/insta360-x4/preparation.json").read_text() + ) + if ( + after != profile["version"] + or state.get("state") != "complete" + or state.get("revision") != profile["revision"] + ): + raise RuntimeError("Обновление установленного драйвера X4 не подтверждено.") + return {"state": "updated", "version": after, "camera_verification": "not-performed"} + + def main(): if os.geteuid() or sys.argv[1:]: raise ValueError("Use the release's local Ubuntu installer") @@ -163,7 +225,7 @@ def main(): "started_at": datetime.now(UTC).isoformat(), "monotonic_started": time.monotonic(), "state": "running", - "scope": "Node-package-and-UI-only; X4-prepare-remains-in-application", + "scope": "Node-package-UI-and-existing-model-update; first-X4-prepare-in-application", "steps": [], } env = { @@ -238,6 +300,8 @@ def main(): ) if installed != version + "\tinstall ok installed": raise RuntimeError("Версия установленного Node не подтверждена.") + report["x4_bundle"] = update_installed_x4(run) + publish() run( "node-service", ["/usr/bin/systemctl", "is-active", "--quiet", "mission-core-node.service"], diff --git a/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md b/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md index c317bfc..f61380e 100644 --- a/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md +++ b/docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md @@ -1468,3 +1468,327 @@ d915221ddb18da1b37446571911e81bc9fe303de59fe37e8dec2c0715f19b623. Физическое выключение камеры владельцем ещё не выполнено/не подтверждено; отсутствие самопробуждения остаётся pending. USB должен оставаться подключённым. После приёмки отмены вернуть рабочее recovery. Системных изменений вне пакета нет. + +### CANCEL01-OWNER / OBSERVE — ручное выключение при отключённом recovery + +Владелец сообщил: выключил X4 кнопкой Power при оставленном USB; позже +появилась индикация зарядки, полное подключение не уверен. Readback +10.09.2026 11:40:54.276119 UTC,Mac monotonic460934.291810458: Rover online, +X4 offline, recovery disabled/preview_wanted=false, прежняя закрытая session; +D455 streaming с прежней session, color25605. Offline snapshot сохраняет +последнюю timestamp настройки11:34:29 и не считается свежим camera status. +Private cancel-owner-after.json SHA256 +37b3f4d62bf253b23fa0d58ae5a133f7b7ace574236005e9e68a8d0475609474. +Независимое read-only sysfs чтение11:42:10.822955 UTC, +Ubuntu monotonic338838.704054695: SDK2e1a:0002 и Mass Storage070a:4026 отсутствуют. +Journal старого X4 source: завершён11:39:30 UTC, новый source не запускали. + +CANCEL01-OBSERVE intent:160s только Core inventory с интервалом4s, +source SHA256e2fffc21d5dd15a3caf2d8f402680107e8cc68ca7ad8b4a7e9ac9377223bc543. +Complete11:42:14.958444–11:44:55.419450 UTC, +Mac monotonic461014.973603958,duration160.500588958s. Все40 samples: +Rover online,X4 offline,recovery=false/preview_wanted=false,D455 online и +прежняя session; color26804→29127. Private cancel-observe.json SHA256 +d90483f72953d09f61238a0e4853cefe00157872dcd5c3bbbe7c86a240a1c656. +Observer штатно завершён; отсутствуют SDK/SD/USB/BLE writes или установки. + +Независимый engineering reader только sysfs, BlueZ get-property и systemctl show; +source SHA256448913b34c1eeb3bf2275b4d5c746f3e630acbbe48462ff422277d31eba2d52d. +Передан на stdin обычного Ubuntu python, без staging/install/root/изменений ОС. +Readback11:45:08.981438 UTC,Ubuntu monotonic339016.86253016: SDK/Mass Storage +X4 отсутствуют, BlueZ ActiveInstances0, broker/wake/D455 active,NRestarts0, +прежние ActiveEnterTimestamp. Private cancel-usb-after.json SHA256 +43e13e1c1427f585752b19515d3b8e8af607ba90bfe7527bdb058075931c3d14. +Принято отсутствие автоматического возврата в SDK в этом ограниченном окне. +Индикация зарядки не сопровождалась рабочим подключением; электрическое +power-off и непрерывная radio-активность не измерены. + +Владелец получил следующий шаг — короткое Power при оставленном USB. +Пока X4/recovery выключены; вернуть recovery и preview можно только после +fresh exact-instance USB/SDK. Подготовлен, но не исполнен private re-enable +instrument SHA256b6ab26fdcb9f77ba59c35a6133f2f9bd691aca387dd498ffc9de752346124bc0. +Это штатный Core recovery.configure(enabled=true,wakeup_confirmed=true), +без обхода обязательного USB enrollment. Новых системных предпосылок нет. + +### MANUAL01 / B16 — разовое включение из списка устройств + +Новое поручение владельца заменило просьбу о физическом Power: добавить +Bluetooth-включение в строку X4 Core/Node. Камера остаётся выключенной с USB, +recovery=false. Подготовленный re-enable instrument не выполняется. +UI placement/state/installer ownership в docs16; используются существующие +DG Button/ResourceRow, без новой визуальной сущности или навигации. + +Реализованы power.wake с durable per-instance receipt, private wake-once, +отдельное сохранение OS identity без auto-enable, общие rate limit/adapter lock; +типизированные rowActions в SensorUiContribution, один renderer Core/Node. +Node owner installer обновляет только уже установленную модель через fixed +bundled profile, не требуя online X4; первая установка остаётся в приложении. +Локальные synthetic recovery22 и installer4 tests passed; Ruff/diff check passed +после форматирования. Реальных команд wake/install пока нет. +B16 source84a4c65393cfe8adcd91ae05,58354434 bytes,SHA256 +c2d894555b3497a462a1e4988a148c3036e81b433755970cbbde818dc8176adb. +Следующий шаг — квалификация model0.1.3-8 на той же Ubuntu, private build +limits1GiB/180 CPU seconds/nice10; не обращается к SDK/USB и не устанавливает пакет. + +B16 complete10.09.2026 12:00:53.222213–12:01:16.437398 UTC, +Ubuntu monotonic339961.103303344,duration23.215211857s. 85 synthetic tests passed; +cold imports/ELF closure passed, SDK not executed, clean OS image not tested. +Model0.1.3-8 revision80467edbdccb8b2f2cf790a3,58211604 bytes,SHA256 +0e9226aef58cc52835c8869ae97ee90500538061bdbaaf7558f469a6a87b18ec. +Result retrieved into private qualified-b16; regular single-component archive +entries checked and model bytes/hash verified. Node bundled profile pins these bytes. +Core fleet31 tests passed locally (one existing Starlette/httpx deprecation warning). + +N12 source82d8db1dcade151ab3fdea9f,2731 files,309771215 bytes,SHA256 +72148a335c9a17be7f8a7af36206ffb1f682a6bacd9c90ddf6cd57363d0646a6. +Copied to private /var/tmp artifact and matching SHA checked before execution. +Versioned builder started on the same Ubuntu: 3GiB/150%CPU/256tasks/1200s, +DG/Node/Core checks sequential, no OS install or camera access. Expected Node0.8.21. +No hardware wake or runtime install performed at this point. + +MANUAL01 instruments prepared, not yet executed: Core activation source +b1f5ad50f5c945be2699b2b19476594b7a6ae1c5ed12220a6250e04b8ad31aae +verifies N12 source and dist hashes, preserves old dist/sensors and private +Core/binding identity hashes, restarts only canonical launchd8000, checks health. +P12 D455 stop sourcec3c9a05c0d1135a7ec4a3f2741293f22f764c58612bd9e277dfb8818dcdcc864; +restore source149c04db68567622568500dcbc07948dccc49f1c59fb02619d6720139bd307fe. +Both use only existing Core operations, refuse recording/playback, no SD replay. +Stop is required by shipped Node preinst before update; restore follows acceptance. +MANUAL01-UI read-only observer source +1f2218342dd7ea8be1f929d057d72f42408ba646ff65ea16d6a64493982a3957: +max100s inventory/operation readback around one actual canonical UI click; +requires offline registered X4/recoveryfalse, then one complete wake and +fresh online preview0/recording0 with policy unchanged. No helper/SDK direct call. + +N12 complete12:07:41.257112–12:16:28.377875 UTC,duration527.120759539s, +peak cgroup memory3221225472 bytes within declared cap. DG build/typecheck/ +registry/loading/catalog; Node UI test/build, Go format/race tests/binary; +Core architecture/typecheck821 tests/build all passed. Existing Python/Rerun +native-RRD generator case remains outside qualified build inputs. DG dependency +attempt1 exit146 and Node UI dependency attempt1 timeout45s recovered by existing +bounded builder retries with unchanged source/lock; logs preserved, no OS changes. +Node0.8.21,168274816 bytes,SHA256 +ea47f4f67953962cc88bd08c3a6978afe3c83f0bc0f97ff47610faaef6df77e3. +Core dist23068985 bytes,SHA256 +7c5bfbca84b127cb3bce1e13766f23019663d484cb3053e971b17dacdb1a51bd. +N12 result SHA256a5fc96d285a101dae63dcfbd7db87b1e7b36593525c0ab2997d3096a2b746006; +retrieved archive regular entries and every qualified payload size/hash verified. +Owner releaseb430d57dd7810d56b77029d3,168296413 bytes,SHA256 +3194c6eec152edd7ee45392e18c0338d15f916405fb319e6ed5691d37c75a10c. +Transferred to /var/tmp and hash verified; next fixed --plan then owner-authenticated +--launch only after D455 idle. It upgrades existing model via packaged profile; +X4 remains off and recoveryfalse. Runtime installation not yet performed. + +P12 Node/model update complete: APT --plan upgrades only Node, removes0. +D455 stopped via Core12:18:19.846114–12:18:25.904136 UTC, +Mac monotonic463179.862859875; reportSHA256 +d4597f273de03122f6fd76875dd14dcc4c50ac3c14afedbde9456b52c9f9ef75. +Owner entered local Ubuntu password. Fixed installer complete12:20:01.318373– +12:20:28.737425 UTC,27.419046572s; Node0.8.21/model0.1.3-8, x4_bundle updated, +camera_verification not-performed, local UI ready. X4 OFF throughout upgrade. +D455 restored via Core start(record=false), private reportSHA256 +818bdcb1be2354ca91aabe8b367c88a2f0907536d0ba1976c6d3a108d5ed4285. +Core activation complete12:18:55.842419–12:19:28.777985 UTC, +Mac monotonic463215.858595791,32.935406667s; PID65164→70307, identity/bindings preserved. +ReportSHA2565f40b4a82c2d81decaf05543dd4362b49704d5a2a6589c6e08b17a7ee501771d. +Fresh Core/Node readback OFF X4/prepared/configured/wake_availabletrue/recoveryfalse; +private installed.json SHA256228397f01e2d6c14c01395d9d8a311f6cddc3fe136cc64e06029dfcf48263cae. + +MANUAL01-UI partial hardware acceptance and timing defect: +Core canonical list rendered active «Включить» beside settings/eye on offline X4. +One real Button click at12:22:23.870 UTC; in-button centered loader visible, +selected X4 controls disabled, D455 controls unaffected. No outside row loader. +Operation ended error12:22:46.592820 UTC (generic no USB return), but X4 returned +later: new SDK session12:22:57.633789, first fresh Core online12:23:00.849852 UTC, +about36.98s after click; no physical Power or second wake requested/performed. +X4 preview0/recording0; recoveryfalse/preview_wantedfalse throughout99 samples; +D455 retained post-update session, remained streaming. Observer100.77s ended +failed because receipt did not match eventual success; evidence preserved. +Private observe-button.json SHA256 +4b664ffc50514a2f7139d83a8088c1b2720afd85a2d1022735cfc65dde2ec1b1. +UI became online/«Включить» disabled, but stale failure toast remained. This is +not accepted as correct end-to-end operation confirmation. + +B17 fixes only bounded confirmation after single beacon: if helper's20s window +ends without USB, broker continues read-only supervisor observation within52s +from call start; exact device_id online required, no second beacon/SDK/SD action. +Other errors retain immediate refusal. Synthetic late37s/other-instance/deadline +regressions added; recovery24 tests and Ruff passed. UI code unchanged from N12. +B17 model0.1.3-9 sourcef516f986fe637261d37eab32,58354797 bytes,SHA256 +f7616990ad2c3f5496fdfc05dd00147fc77128e80e5cdac0542a9622a840975c. +Next qualify model then Node0.8.21-1 bundle through existing node-only builder; +Core N12 already qualified and active. No runtime hot patch/ad-hoc install. + +B17 complete12:27:59.001757–12:28:22.799881 UTC,Ubuntu mono341586.882843768, +23.798147363s;87 synthetic tests/import/ELF closure passed, no SDK/hardware. +Model0.1.3-9 revision0b1085386d53e65760a9610f,58211732 bytes,SHA256 +eb6331680fbe8cac56eb745c31d7c37618a9dff67bc2a190681a384813cdc709. +ResultSHA256520d23f362a7017eb76d0f5a2d78a051096d3809617f17da7a3d2e2386174033, +retrieved/extracted with path/type checks, package manifest bytes verified. +Owner confirmed physical OFF again solely to prepare MANUAL02; USB retained. +Fresh readback X4offline/recoveryfalse/wake_availabletrue, D455streaming; +private off-for-retest.json SHA256 +bcecc77fb546b6c9cf82b9bcc34dda407d27c43d9f099afd0f53a84860bfc496. +MANUAL0199 samples independently checked: policy/preview intentfalse, all54 +online samples preview0/recording0, D455streaming throughout. No unreported effect. + +N13 node-only source0e3e40dea941828d9d6ecda9,2731files,309775674 bytes,SHA256 +17e3c48a840e78600b89ea0fe90b690725da0cb33265eb7de5e77f3a0872c879. +Versioned private /var/tmp build started after matching remote hash, same3GiB/ +150%CPU/1200s cgroup. Core UI stays exactly qualified N12, no new Core build. +P13/MANUAL02 private instruments adapted without new effects/authority: +D455 stop7d9aae26179d4fe5ed096ac2d14eed629d08aea0d0c2cd98f08870b53088996d; +restore9b2c343db134c557e52a90725f28ef513b741abc8d994859837353e56bf57be1; +read-only button observerc793f7251a319114a0b1ef0e0ba9afb748f76bc5bff1d772bd7f032d0bd05a9c. +They are not yet executed; one-shot hardware acceptance remains pending. + +Final hop audit found Node driver HTTP client25s would truncate B17's52s +confirmation. Added per-model ActionTimeouts: X4 power.wake55s only, client copy +retains shared inventory25s and earlier context deadline. Synthetic transport +regression tests actual request deadlines, no long sleep/hardware. This is code +needed for correct end-to-end confirmation, not a broader timeout increase. +N13a0e3e40dea941828d9d6ecda9 had already completed before attempted stop of its +exact owned transient user unit; no runtime service affected. Not released. +N13a resultSHA256a82e88487095d31aacf70b4fcfc3ada23af4af361cbcbd1f78ee28bf2fb201f8. +Three Go files formatted through stdin/stdout of already qualified private +Go formatter; no Ubuntu file/runtime changes. Runtime sourceSHA256 +sensor_models.go9075e0d37e1467fe1a98a5e286248ec23de2765c32189cedeb1bb9b29c4b3e5f, +sensors.gof0150bdfd4614b8a9be2d398493fc231f42839fba4bf71364010f551aac96342; +regression sourceSHA256a674b44e2d924ba3517393dbec6c869393deab6be6ac6e6a028a06d878a3403d. +N13b final source9aba79359708eca5ec14c762,2731files,309776910 bytes,SHA256 +b086b7ac3dd7e3ebc88db20d9af2b4dd21ca607f263706e475fb34e09598c1d9. +Next same versioned node-only builder; B17 and Core N12 remain unchanged. + +N13b complete12:38:20.733418–12:42:10.406938 UTC,229.673523105s, +peak2884808704 bytes. All DG/Node UI/Go race tests and binary build passed, +including new actual driver deadline test; no failed/retried jobs. Core UI remains +N12 exact bytes; no need to repeat unchanged821 tests/build. Node0.8.21-1, +168275612 bytes,SHA256a88ef1a4349a3d3188ca88b5cb87bf87d38beb68a2bdd89eadfa192e464ba36d. +ResultSHA256de7a8d70e0a622b7cd3d03743e2a2a3b074cecdac1df1b6d5551100f7f4d2778; +retrieved, archive entries and qualified bytes verified. N13a is superseded and +not admitted for installation; its private qualification report retained separately. +Final owner releasea0f5da65627a49343e9b912a,168297217 bytes,SHA256 +e698646dfe918efc64abdccf3ddcac838ef47f3a5599e7e4903f6abcef261f93. +Next --plan/--launch after D455 idle, fixed model profile update, no runtime +hot copy/SSH sudo package workaround. X4 stays OFF/recoveryfalse for MANUAL02. + +P13 plan upgrades Node0.8.21→0.8.21-1 only, removes0. D455 idle through Core, +reportSHA256752b6b6769a90f5fca48826e1b53e5168d18202f2a7fa275ed392b38b6500d97. +Owner installer window0.8.21-1 opened; latest readback before subsequent UI QA +showed no install steps yet, waiting local sudo authentication. X4 remains OFF. + +Normal-width UI QA exposed title collapse beside the new text Button: configured +OFF device incorrectly supplied a full StatusBadge into DG leading compact slot. +Corrected common SensorWorkspace to indicator for every configured status; +full label remains aria-label/title. Unconfigured trailing badge unchanged. +This uses existing DG ResourceRow/StatusBadge contract, no new CSS/control/icon. +DG8c53f73 remains unchanged. Error toast from MANUAL01 dismissed; normal/expanded +controls work; detailed geometry recheck follows qualified UI build. +Node0.8.21-2/Core UI rebuild required for this shared row fix; B17/native/wake +code and timing unchanged. No unqualified UI hot copy or Ubuntu runtime patch. + +N14 source30bb5fd6e468f8f024e1d059,2731files,309778048 bytes,SHA256 +37362a7e002f8034d55f6e8329b657297233fe04568ea248cef7198466387315. +Same immutable Node-Core builder/cgroup, final source includes compact configured +status plus B17 and per-action deadline. Full Core validation required for changed UI. + +Latest P13 launcher readback still had zero steps/results: no installation yet. +Owner informed to defer0.8.21-1 password in favour of combined0.8.21-2. +D455 restored through the already declared P13 Core instrument while waiting; +no wake/preview/SD command to X4. P14 stop/restore instruments prepared with +same guards, sources048a3a859d3c68ae40a0df33714568a941b88d3b103f39fb1210cd7ad19a3746 +and88310d000694459e3f13062ede7ed8b38f1c201975d5e1d853294c4b4b95f3e6. +MANUAL02 read-only observer source remainsc793f7251a319114a0b1ef0e0ba9afb748f76bc5bff1d772bd7f032d0bd05a9c. +Core N14 activation instrument source505cbf8e8459e0547b1d3c668e898be8c7a7e81ee57034de405606585a0dc8ee: +fixed source/dist hash checks, before backup, only canonical launchd restart, +identity/health verification. It has not run; N14 UI acceptance remains pending. + + +### N14 qualified / OWNER-MANUAL-NODE / N15 online action visibility + +N14 complete12:52:26.280386–13:01:00.883274 UTC, mono343054.161483472, +514.602917375s. DG/Node/Go race/Core architecture/typecheck,821 Core tests and +production build passed; one bounded Node UI dependency timeout recovered on +attempt2. Known native RRD fixture remains explicitly unqualified, not a passed test. +Node0.8.21-2:168275654bytes,SHA25680279b8ea7babe66072a05b7a2b4c331fdc736fbe3b7e9698a5b35fb3e0a719e. +Core dist23068479bytes,SHA2566c55344b8fc63f090963900f8e4c6115f0cb6148dcb610c78605107befc2da35. +ResultSHA256faa4502455694acd00e17c41596ffe0c3fb03cd44949dfe1da2d20f6f986ad8f; +retrieved and all archive paths/payload hashes verified. N14 Node not installed. +Core activated13:07:38.339686–13:08:11.002978 UTC, mono466138.326959791, +32.663109750s; canonical PID70307→73387, identities preserved, operational8000. +Activation reportSHA2568f27abede8e0453fe578baeee26450620074ad883e706d40c20f1bcc47ca32bd. +Normal narrow row, expand/restore and settings Escape visually passed in canonical +in-app browser at13:08 UTC: title readable beside Button, compact status fits. +No viewer/preview/recording command or second backend created. + +Owner independently clicked wake in the Ubuntu Node application and confirmed +camera ON. Read-only board versions remain Node0.8.21/model0.1.3-8: superseded +P13 update was not applied. Core fresh readback: same X4 instance/new SDK session, +online, battery100, recovery=false/preview_wanted=false, preview0/recording0; +D455 same streaming session. This is owner-reported Node UI wake success with +SDK readback, not a measured command receipt or acceptance of the B17 timing fix. +Owner requests hiding the power button once connected. Shared X4 rowActions +now returns no power action for online devices; offline eligibility unchanged. +Existing visibility regression updated. Node0.8.21-3/N15 bundles the final UI with +B17; full shared UI validation follows. No system/runtime hot patch. + +N15 sourcefee6989f47dd42ee2bb56e75,2731files,309780200bytes,SHA256 +0c915e46b0714d3fcc94bacf2774a9b763e9e7e739bc83fbd6a5d4fa73ed6d08. +Staged and SHA verified on existing Ubuntu; same bounded user-cgroup builder +started for full Node/Core qualification, no runtime changes. B17 bytes unchanged. +Core N15 activation prepared from N14 fixed instrument; sourceSHA256416b811889e2f680eeb2154ba3b5a87053a87d027e2922def395248647e013b0. + +OWNER-MANUAL-NODE readback13:11:32.904441 UTC,mono466372.890483, +reportSHA2560d217f2701c6b62505beae99608882e6b2143e5cd7312a8e76319ab1070dcd9a. +Private report persisted before a display-only summary lookup used missing kind +key; re-read by stable device_id succeeded. No hardware command or retry occurred. + +Late readback found superseded P13 had actually been submitted locally at +13:00:19.894546 UTC (after prior zero-step observation), and ended13:00:27.757015, +7.862448686s with package step exit100 after baseline/apt-plan complete. +No successful package/model update: fresh dpkg readback remains Node0.8.21 and +model0.1.3-8, both install ok installed. Detailed root step output was not read; +exit100 alone does not prove its cause. D455 was streaming at this period. +Private owner tee logSHA2562231854b4114766dc9be332d9f6b7c58ea8ce39fd3f7f2d33612f588fd06697b. +No dpkg transaction was killed or manually repaired. Final release must pass +its own apt plan and guarded idle installation; D455 stop/restore already prepared. + +N15 complete13:11:06.346138–13:20:02.834198 UTC,mono344174.227231158, +536.488056009s,peak3221229568bytes. DG/Node/Go race/Core architecture/typecheck, +full Core tests and production build passed. Node UI dependency timeout45s recovered +on bounded attempt2. Native RRD generator-dependent case remains unqualified. +Final Node0.8.21-3:168275654bytes,SHA25649f3611b9a2f9a9f117c849d42df78a5fba2dafb6143b30f2855f92ea9a719ae. +Core dist23068797bytes,SHA256e7ae3a57b96c918811b380661dd1ea20ab13b7e2c4d73d92b468910d05e38b3e. +ResultSHA256de5a6130866d37172858ed30d03015c14083a741fa093298ec4e736cbdec2ab4; +archive/payload validation passed. All22 changed runtime/test/package source files +match the frozen N15 source; only human status documents updated afterward. +Owner releaseabbcb90ff14dcb05b99eeedf,168297259bytes,SHA256 +5b58f654ec3136de5028603734868880a843c42ba5c38e86c12730eeb8456435. +Next guarded plan/install; bundle B17 exactly as qualified. No manual system repair. + +N15 Core activated13:21:43.450275–13:22:15.064260 UTC,mono466983.423901458, +31.613767250s; sole canonical PID73387→74340, identities preserved/health operational. +ReportSHA25638ce422822b25f9f5f69a3aba320c76c302dc3c847947ffb16a85eb242acc12d. +Core tests:821 passed,0 failed. Final in-app browser QA13:23UTC: actual connected +X4 has no power button in normal/expanded row; settings/eye remain; settings Escape +closes without mutation. Narrow title readable, no extra loader. No preview/SD command. + +Final P15 owner artifact SHA verified on Ubuntu; --plan admits one Node upgrade, +0new/0removed packages. Existing B17 profile is carried in Node. D455 stopped +only through Core stop operation before launch; reportSHA256 +eeb4516e75e4280cdbbc3c916c4f9cdec3c4ebbf268bbf7c767f9475c4f0f6b6. +--launch opened local Ubuntu window0.8.21-3. Owner asked for required local OS +password in this final window, then camera Power-off with USB kept for MANUAL02. +No password accepted through SSH/chat; no ad-hoc privileged repair. + +P15 complete13:24:18.998120–13:24:46.634699 UTC,mono344966.879222148, +27.636570683s. All steps complete; Node0.8.21-3, existing X4 model updated through +fixed bundled profile to0.1.3-9, ui_ready=true. camera_verification not-performed +as declared by package update. Both dpkg statuses install ok installed. +Owner tee logSHA256b9c687f468425d58f010a9574a36d82455a965659f17354e85ac5a9cf6f1b7db. +D455 restored with record=false after install, reportSHA256 +67a8ee22d2d18fe85ed57f1a45fb8044b2b0ef8b99fb5c5e08a0def74b57bf11. +Fresh Core readback: same vehicle/node/device IDs, both cameras online; +D455 streaming; X4 idle/preview0/recording0/recoveryfalse/preview_wantedfalse. +Owner informed installation completed and no further password needed; only +Power-off with USB retained is awaited for MANUAL02. No new wake issued yet. diff --git a/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md b/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md index 3d84365..1664c70 100644 --- a/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md +++ b/docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md @@ -1,5 +1,25 @@ # Insta360 X4 — состояние реализации 10.09.2026 +Актуально10.09.2026: установлены Node0.8.21-3 и X4 model0.1.3-9 (B17/N15). +Обновление27.64s прошло через штатный owner installer и bundled profile; +Core активирован на8000, identities сохранены. Ручное включение ранее +подтверждено из Core и приложения Ubuntu Node. После обнаруженной задержки +загрузки X4 до37s B17 ждёт подтверждение до52s после единственного20s BLE +beacon; Node transport ограничен55s.87 модельных тестов и полная N15 +квалификация прошли:821 Core tests, architecture/typecheck/build, DG/Node/Go race. + +По последнему требованию владельца кнопка «Включить» полностью скрыта у online +X4, возвращается при offline. Обе UI используют общий frontend. В Core +проверены реальная online камера, нормальная/развёрнутая строка и settings Escape. +X4 сейчас online/idle, recovery=false/preview_wanted=false, запись0; D455 +восстановлена и ведёт захват. MANUAL02 для позднего complete receipt остаётся +pending: владелец получил шаг Power-off с сохранённым USB; повторного wake +в N15 ещё не было. [Контракт](16_INSTA360_X4_MANUAL_WAKE.md), +[план](10_INSTA360_X4_NEXT_STEPS.md). + +Ниже — история доказательств; версии и прежние формулировки следующих шагов +относятся к указанным опытам. Последний статус задаёт абзац выше и ledger07. + ## REC01–02 / LIVE08–09 — два последовательных цикла восстановления приняты [Per-instance recovery](15_INSTA360_X4_AUTOMATIC_RECOVERY.md) добавлен в broker @@ -36,11 +56,23 @@ REC02/LIVE09: второй предложенный USB out/in подтверж подтвердил 2 кадра за 124 ms. D455 сохранила session, color10318→17512; службы не перезапускались. Два последовательных возврата SDK/preview приняты. -Текущее состояние CANCEL01: восстановление намеренно выключено через Core -11:34:30 UTC для следующего теста ручного Power-off с оставленным USB. -Readback enabled=false/phase=disabled/preview_wanted=false; просмотр продолжает -работать, запись0. Владелец ещё должен выключить камеру; отсутствие самопробуждения -не принято до наблюдения. После проверки нужно вернуть рабочее recovery. +CANCEL01 принят в ограниченном окне: после отключения recovery в Core владелец +выключил X4 кнопкой Power, оставив USB, затем видел индикацию зарядки. +За 160.5 секунды, 40 последовательных samples, камера не вернулась в SDK, +recovery disabled/preview_wanted=false; D455 сохранила session и поток. +Ubuntu read-only11:42:10 и11:45:08 UTC: нет SDK/Mass Storage enumeration X4; +последний BlueZ ActiveInstances0, службы без рестартов. Индикация зарядки +не сопровождалась рабочим подключением. Электрическое состояние питания и +непрерывная radio-активность не измерялись. + +Исходное состояние MANUAL01: камера отключена, recovery намеренно выключено. +Владелец заменил физическое Power на запрос кнопки «Включить» в строке X4 +Core/Node. Реализован разовый power.wake без auto-enable/preview/SD; B16 +model0.1.3-8 прошёл85 Ubuntu tests, Core fleet31 tests passed. N12 Node0.8.21 +позднее прошёл квалификацию и установлен в P12; MANUAL01 результат указан выше. +Обновление уже установленной модели выполняется owner installer через fixed +bundled profile даже при offline X4; первое prepare остаётся в приложении. +Подробный контракт и приёмка — [MANUAL01](16_INSTA360_X4_MANUAL_WAKE.md). ## WAKE01 — первый успешный Bluetooth wake с возвратом Android @@ -166,7 +198,7 @@ X4 — операторский несшитый просмотр и управ Форматирование, удаление файлов, firmware update и power/reset не включены. SD recording не повторялась для запуска LIVE04. SDK boolean capture status и поведение firmware при потере USB требуют отдельной аппаратной проверки. -## Версии и состав принятого артефакта +## Исторический P08/P09 — версии и состав артефакта - Node.deb0.8.18-1:168257000 bytes, SHA2569e5aaff4a8e4a2ed24cef56bcc9bd9721971df3154fa48be8595fef8591bdbb3. P09 complete09.09.2026 в04:14:52.735120 UTC; binary/UI0.8.18 сохранены. - X4.deb0.1.3-2:58198804 bytes, SHA2562931a5206478fcac67f74bd1fb579b98c0fcc43eda4c05e5ac4a721947619130, runtime revisionf78a7ae557e3371022869025. diff --git a/docs/node/10_INSTA360_X4_NEXT_STEPS.md b/docs/node/10_INSTA360_X4_NEXT_STEPS.md index 34a7c41..948cc13 100644 --- a/docs/node/10_INSTA360_X4_NEXT_STEPS.md +++ b/docs/node/10_INSTA360_X4_NEXT_STEPS.md @@ -1,91 +1,41 @@ -# Insta360 X4 — следующий план после первого живого просмотра +# Insta360 X4 — следующий план -Срез10.09.2026. Основания: исходный Mission Core Node Context, разделы8/25/26/29; [план X4](06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md), [текущее доказательство](09_INSTA360_X4_IMPLEMENTATION_STATUS.md), [журнал](07_INSTA360_X4_INSTALLATION_LEDGER.md), последние решения владельца. Исторический нулевой baseline не отменяет PREP04/LIVE04; наличие live stream не закрывает весь P4/P4C/P7. +Срез10.09.2026. Основания: исходный Mission Core Node Context, разделы8/25/26/29; +[план X4](06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md), +[результаты](09_INSTA360_X4_IMPLEMENTATION_STATUS.md), +[журнал](07_INSTA360_X4_INSTALLATION_LEDGER.md) и последние решения владельца. -## Подтверждённая исходная точка +Ближайший шаг — завершить [MANUAL02](16_INSTA360_X4_MANUAL_WAKE.md). +Ручное включение уже вернуло камеру из Core и локального Node. Исправление B17 +должно подтвердить возврат через52s окно, без повторного BLE beacon и ранней +ложной ошибки. N15 Node0.8.21-3 объединяет его с общей UI: у online X4 кнопка +«Включить» отсутствует, при offline возвращается; pending только внутри кнопки. +N15 прошёл полную квалификацию, Core активирован; исчезновение кнопки при +online и геометрия проверены. Node0.8.21-3/B17 установлен штатно, D455 восстановлена. Остался +новый hardware receipt MANUAL02 после физического Power-off владельцем; +USB остаётся подключённым, recovery выключено. Промежуточные Node0.8.21-1/-2 не установлены. -X4 подключена к SDK на той же Ubuntu Mini. Удалённая подготовка через Core разворачивает встроенный model package и проверяет exact instance. Без SD-записи работают первый WebRTC START, viewer reopen и STOP→START;1280×640, несшитые изображения двух объективов. SD START/STOP отдельно подтверждены ранее. Это одна физическая камера и ограниченная лабораторная проверка. +## Что уже подтверждено -Дополнение LIVE05: пользовательский stale preview восстановился после одного явного STOP→START,100-секундный приём подтвердил свежие кадры. Причина исходного зависания ещё не исправлена. Verify во время живого потока дал ложный отрицательный результат; ближайшая реализация должна проверять свежий общий decoder при активном preview и сохранять адресную причину ошибки, не создавая второго владельца SDK/не перезапуская capture неявно. Не путать это восстановление с завершённой приёмкой устойчивости. +- Подготовка и управление через один контракт Core/Node, versioned model package. +- Удалённый несшитый WebRTC1280×640, repeat START/verify/viewer reopen; + SD START/STOP проверены отдельно. Это не измерение glass-to-glass latency. +- Core–Node восстановлены через Tailscale с сохранением trust/identity. +- Два последовательных USB out/in при recovery=true вернули SDK и preview + без ручного START;339/326 WebRTC кадров за30s, D455 сохранила session. +- После recovery=false и ручного Power-off за160.5s/40samples SDK не вернулся. +- Один адресный BLE wake возвращает X4 в Android/SDK; ручная команда не включает + recovery и не запускает preview/SD. Корректное позднее подтверждение B17 pending. +- PORT01: root hubs Mac mini не поддерживают адресное снятие VBUS. Электрические + измерения отсутствуют; управляемый хаб остаётся отдельной аппаратной веткой. -B11/P09,09.09: эта реализация готова и прошла38 Ubuntu tests. Дополнительно исправлен доказанный code path, в котором idempotent повторный START очищал feed без нового keyframe. Первоначально model0.1.3-3 ожидал штатной подготовки; ниже зафиксирован завершённый PREP05/LIVE06 на Node0.8.19. Остальные причины возможных разрывов и длительная устойчивость остаются в плане. - -Предварительное условие NET01 закрыто NET02/NET03,10.09: Core–Node восстановили канал через Tailscale с прежними trust/identity. В Core подтверждены несколько свежих mTLS heartbeat, endpoint revision1. После восстановления заново читать актуальное состояние камеры перед любыми preview/model операциями; прежний stale inventory не подтверждает её текущее состояние. - -P10/PREP05 complete: Node0.8.19 и X4 model0.1.3-3 установлены штатно. LIVE06 подтвердил первый и повторный START, active verify и viewer reopen:1083 декодированных кадра1280×640, без SD записи. REPLUG01 выявил отказ active USB replug; REPLUG02/LIVE07 восстановили камеру после camera off/on. OWNER03 подтвердил автоматический Android mode после этого включения. REPLUG03 подтвердил такой же отказ при fresh idle/preview0. Владелец исключил замену кабеля из ближайшего gate. PORT01: порты/контроллер активны, early_stop=no, перегрузок0; измерителей VBUS/тока нет. Root reader подтвердил No power switching у обоих xHCI root hubs: штатно снимать VBUS встроенного порта нельзя. RESEARCH01 уточнил приоритет: сначала беспроводное чтение внутреннего udisk_mode и проверка программного возврата Android либо BLE off/on самой камеры по плану12. Управляемый хаб — отдельная аппаратная альтернатива; отсутствие VBUS switching не доказывает невозможность программного обхода. Recovery SDK/preview — после возврата USB. Задержка NET03 recovery и длительная video stability остаются открытыми. - -OWNER03: владелец видит изображение X4 и D455 через Core при переходах между карточками. Read-only Node snapshot обеих камер online/streaming. Длительная одновременная работа и измерения ещё не приняты. - -BLE01/BLE02: [первый диагностический модуль](13_INSTA360_X4_BLE_DIAGNOSTIC.md) -реализован и включён в исходники model package0.1.3-4. На Ubuntu два bounded -наблюдения не обнаружили X4; экран камеры был тёмный. POWER03: владелец -подтвердил короткое Power с уже вставленным USB → Android без выбора режима; -тот же instance автоматически получил новую SDK session. BLE03 теперь видит -одного свежего BE80 кандидата с именем, совпадающим с USB serial suffix; -это не полная BLE authentication. Следующий hardware шаг — установить состояние -Bluetooth Wakeup и квалифицировать адресный wake artifact. GetOptions остаётся -отдельной веткой при отсутствии активного USB SDK owner. Ни setter, ни BLE -power cycle ещё не выполнялись; POWER03 не заменяет автоматический тест. -Node0.8.19 bundle пока остаётся0.1.3-3; его обновление не считается завершённым. - -## Очередь реализации - -**REC01 в работе:** код per-instance wake/preview recovery и настройка Core/Node -реализованы; B15 model0.1.3-7 прошёл75 Ubuntu tests. Node0.8.20 и X40.1.3-7 -установлены штатно; общая UI активирована в Core, identities сохранены. -REC01/LIVE08 принял один реальный USB out/in: владелец подтвердил Android, -прежняя X4 получила новую SDK session и preview без ручного START. Получено -339 кадров 1280×640 за 30 секунд и успешный active verify; D455 сохранила -свою session. REC02/LIVE09 повторил результат: новая session X4, 326 кадров -и successful verify без START/STOP, D455 непрерывна. Два цикла приняты. -Ближайшее действие — CANCEL01: recovery уже штатно выключено через Core; -владелец выключает X4 кнопкой Power при оставленном USB, затем наблюдаем -отсутствие автоматического пробуждения. После проверки вернуть recovery; -следующий отдельный gate — cold start. Два цикла не закрывают всю матрицу -устойчивости/ограниченных retries. -Архитектура и границы — в docs15. - -**Актуальный результат WAKE01:** один адресный Bluetooth wake с Ubuntu -разбудил X4 при подключённом USB; камера сама вернулась в Android/SDK, -владелец подтвердил экран. Node открыл прежний instance, D455 сохранила поток. -Модуль упакован в0.1.3-6,59 Ubuntu tests passed, новая модель не установлена. -REPLUG04 без wake остался неуспешным. Ближайший increment: - -1. Сохранять полную identity и явное намерение восстановления отдельно для - каждого instance; не будить намеренно выключенную оператором камеру. -2. Подключить bounded wake и сериализацию adapter ownership; отменять при - появлении SDK, ограничивать retries, не повторять SD-команды при unknown. -3. Доставить через Node bundle и штатную подготовку из Core/Node, затем - проверить возврат SDK/preview, несколько циклов и независимость камер. - -Camera-side Bluetooth Wakeup On остаётся явной предпосылкой. Wake при тёмном -дисплее не доказывает recovery зависшей включённой камеры или clean Ubuntu. -Следующий абзац сохраняет порядок предыдущих опытов; их исходы уточнены ниже. - -Уточнение BLE04/POWER04: Bluetooth Wakeup теперь включён владельцем. GATT -Connect прошёл, но первый identity GetOptions получил неподдержанный7-byte -type5 packet, поэтому без retries/setters закрыт; это отдельный вопрос framing. -Обнаруженный штатный Power-off Charging → Charge and power on проверяется -раньше следующей BLE-команды. Observer зафиксировал возврат Mass Storage→SDK -и новой session; владелец подтвердил самостоятельный Android после camera off -→ USB out → USB in. Один запуск выключенной X4 от подачи питания подтверждён. -REPLUG04 включённой X4 завершился отказом; успех POWER04 его не закрывает. -Ручные camera-side flags фиксируются как prerequisites, не скрытая доработка -Ubuntu. «Зарядка и запись» не используется для операторского preview. - -**Уточнение владельца10.09 после NET03:** ближайший приоритет после применения -B11 — автономность подключения и питание. Проверить сохранение Android-режима, -replug/cold start, автоматическое восстановление SDK и просмотра; определить -реальное VBUS switching существующего Mac mini. Это предшествует расширению -camera settings/file download. Конкретная матрица, доказательства и ограничения -в [плане автономности](11_INSTA360_X4_AUTONOMY_AND_USB_POWER.md); прежняя очередь -ниже сохраняет остальные работы. +## Порядок оставшейся работы | Порядок / связь с исходным планом | Работа | Конкретный выход | |---|---|---| | Завершено, P5 | Общие состояния loading в DG: pending инициирующей кнопки, центр content region, отсутствие бесконечной загрузки после ошибки. Shared frontend для Core и Node, каталог и правила. | UI01/N09 установлен в Core8000 и Node0.8.18. Сборки/819 Core tests, DG tests/catalog и Node checks прошли. Browser geometry, normal/expanded/Escape, light/dark и error lifecycle проверены. Native Node visual QA ещё открыта. | | B11 ограниченно принят, P3/P4 | Исправлено очищение feed при повторном START и проверка активного потока через новый decoder. PREP05/LIVE06: repeat START, live verify, viewer reopen complete. |1083 кадра1280×640; долгий capture и все возможные причины stale отдельно не приняты. | -| Первый следующий, P3/P4 | RESEARCH01: через versioned artifact проверить BLE identity/framing/auth и прочитать Options.udisk_mode (95). Затем при подтверждённой семантике — ограниченный Android setter; резерв — BLE remote off/on при уже вставленном USB. [Доказательства и границы](12_INSTA360_X4_ANDROID_RECONNECT_RESEARCH.md). Хаб — альтернативная аппаратная ветка. После USB enumeration — SDK recovery/preview intent по плану11. | Прежняя X4 без рук возвращается в Android, новая SDK session и свежий кадр; прочие камеры/Node online. Наличие параметра в SDK и ACK не являются приёмкой. | +| Первый следующий, P3/P4 | Завершить MANUAL02 на B17/Node0.8.21-3: скрытие кнопки при online, один wake с корректным complete, policy=false и отсутствие SD/preview replay. Затем отдельный cold start с сохранённым recovery/preview intent и повторные disconnect/retry/cancel. | Прежний instance, новая SDK session, корректный receipt и UI; D455/Node сохраняют работу. Два уже принятых replug не заменяют cold start и всю fault matrix. | | Следующий, P4 + P5 | Совместимый локальный просмотр в установленном Node/WebKit. Исходный план предусматривает HTTP/fMP4 carrier от той же медиа-сессии; переиспользуются общие media lifecycle/lease части K1, без импорта K1 protocol или второго SDK владельца X4. | На Ubuntu в приложении Node есть движущееся изображение; одновременно открыт удалённый Core. Закрытие одного зрителя сохраняет другого, не меняет SD recording. Все runtime dependencies входят в .deb/profile. | | Затем, P4C | Матрица поддержанных действий по фактическим SDK headers и capabilities: camera settings, normal photo/video, ISO/exposure/WB/resolution, отдельные SD start/stop и фото. Проверять readback, mode dependencies и отображение результата. | Явные адресные команды из обеих UI, реальные результаты на камере, неизвестный outcome не переигрывается автоматически. Не обещать все режимы SDK до их проверки; shutter/EV setters и неподдержанные поля перечисляются отдельно. | | P4C + общий source-record workflow | Получение содержимого выбранного файла на Node и в Core, bounded transfer, прерывание/продолжение, длина/hash и происхождение. Каталог уже есть, download ещё нет. | Пользователь получает существующий файл без shell; повторы/обрыв связи не меняют оригинал на SD. Незавершённый записываемый файл обрабатывается явно. | @@ -93,10 +43,27 @@ camera settings/file download. Конкретная матрица, доказа | P1/P3/P6 | Несколько одинаковых X4 и D455, одновременная K1, exact-device routing, isolation/crash/replug. D455 shared-process migration остаётся отдельной реализацией. |2/4 реальные камеры независимы;500 synthetic descriptors не превращаются в обещание500 потоков. Текущий admission4 sources/4 peers/2 peers per camera пересматривается только по измерениям. При отсутствии устройств hardware часть остаётся открытой. | | P7 | Чистая Ubuntu24.04 Desktop amd64 с закреплённым образом; полный installer → discovery → prepare из обеих UI → image → управление; upgrade/repair/rollback/failure matrix. | Никакой системной предпосылки вне installer, repeat/recovery сохраняют identity/trust/recordings. Текущий рабочий диск Mini не стирается. Отдельный чистый носитель/образ — необходимое условие, не предположение о наличии второй машины. | -Критический путь по последнему решению владельца: **беспроводной возврат Android (план12) → replug/cold start/SDK recovery/preview intent → локальное видео Node → приёмка управления/фото → получение файлов**. Адресное питание остаётся альтернативной веткой восстановления. B11 имеет ограниченную аппаратную приёмку; длительная устойчивость остаётся самостоятельным gate. Контракт и учёт clean install проверяются при каждом изменении, финальная установка на чистый носитель завершает воспроизводимость. Общее наблюдение K1/monitor и их открытые fault/backfill/source-import проверки сохраняются в исходной матрице; успех X4 их не закрывает. -Stitching не включается: текущая цель — операторский обзор с низкой задержкой. Worker, AI и управление движением не являются зависимостями. Если нужен сшитый обзор позднее, это отдельный профиль с собственной измеренной задержкой. +Критический путь: **ручная кнопка и корректный receipt → cold start и матрица +recovery → локальное видео Node → приёмка управления/фото → получение файлов**. +Чтение BLE Options/udisk_mode остаётся исследовательской альтернативой при +недостаточности подтверждённого wake; legacy PowerOff/SetMode не отправлять +без доказательства точной X4 семантики. Управляемое питание — отдельная ветка. -## Условия поставки и выполнения +## Условия выполнения -Работа идёт на существующей Ubuntu Mini через versioned artifacts с hashes/UTC/monotonic, без ручных apt/pip/служебных правок. Изменённый DG сначала становится общим компонентом, затем встраивается в обе UI. Внешний релиз vendor SDK требует отдельно разрешённых условий распространения. Полный интерфейс управления не включает неоговорённые destructive operations: delete/format/firmware/power. +Работа идёт на существующей Ubuntu Mini через versioned artifacts с hashes, +UTC/monotonic, без ручных apt/pip/правок служб. Каждая runtime предпосылка входит +в installer или подготовку модели с первого опыта. Общие controls приходят из +Design Guideline и используются в обеих UI. Текущий рабочий диск не стирается; +чистый носитель/образ требуется для clean-Ubuntu приёмки. + +Camera-side Bluetooth Wakeup On остаётся явной предпосылкой. Перед намеренным +Power-off отключать recovery: исчезновение USB само по себе не различает +физическое выключение и потерю кабеля. Ручной wake сохраняет настройку recovery. +Внешний релиз vendor SDK требует отдельно подтверждённых условий распространения. +Delete/format/firmware/power-off не входят в проверенные команды. + +Stitching, Worker, AI и управление движением не являются зависимостями. +Несколько сотен descriptors не означают сотни одновременных видеопотоков; +предел определяется измерениями и независимостью экземпляров. diff --git a/docs/node/15_INSTA360_X4_AUTOMATIC_RECOVERY.md b/docs/node/15_INSTA360_X4_AUTOMATIC_RECOVERY.md index aa9fb1a..0802838 100644 --- a/docs/node/15_INSTA360_X4_AUTOMATIC_RECOVERY.md +++ b/docs/node/15_INSTA360_X4_AUTOMATIC_RECOVERY.md @@ -1,10 +1,13 @@ # X4 — восстановление подключения и просмотра -REC01 / B15, 10.09.2026. Продолжение принятого WAKE01. Штатно установлены -Node 0.8.20 и X4 0.1.3-7, общая UI активирована в Core. Настройка включена -через Core. REC01–02/LIVE08–09 приняли два последовательных USB out/in -с возвратом SDK и preview без ручного START. Для CANCEL01 настройка временно -выключена через Core; физическое ручное выключение и проверка отмены ещё открыты. +REC01 / B15, 10.09.2026. Продолжение принятого WAKE01. На этапе REC01 штатно установлены +Node0.8.20/X40.1.3-7; настройка была включена через Core. Последующие +версии и текущее состояние — в docs09/16. REC01–02/LIVE08–09 приняли два последовательных USB out/in +с возвратом SDK и preview без ручного START. CANCEL01 принял отсутствие возврата +в SDK в течение 160.5s после ручного Power-off при отключённом recovery. +Настройка пока выключена. MANUAL01 добавляет явную кнопку включения без +auto-enable; физическое Power заменено этим запросом владельца. +См. [разовое включение](16_INSTA360_X4_MANUAL_WAKE.md). ## Поведение и полномочия @@ -35,8 +38,10 @@ OperationRequest; unknown не повторяется в той же сесси Новый `mission-core-insta360-wake.service` — отдельный root helper с Unix socket, kernel peer credentials допускают только root и UID model broker. Нет public listener, SDK loading, USB writes, произвольных serial/path/command arguments. -При включении helper сам читает единственный полный serial из текущего USB -binding и сохраняет в root-only StateDirectory. API принимает только device_id, +Helper сам читает единственный полный serial из текущего USB binding и +сохраняет в root-only StateDirectory. Начиная с model0.1.3-8, registry сохраняет +identity при обнаружении независимо от разрешения auto-recovery; выключение +политики не удаляет возможность отдельного manual wake. API принимает только device_id, boolean control либо attempt UUID. Известные совпадения radio suffix запрещены. Один adapter lock сериализует wake для разных камер. Helper независимо @@ -84,5 +89,10 @@ REC02/LIVE09 повторил возврат: новая session от 11:31:11.4 326 кадров 1280×640 за 30 секунд и verify2/124ms; D455 без смены session. CANCEL01-ARM 11:34:30 UTC подтвердил через штатный Core recovery.configure enabled=false/preview_wanted=false/phase=disabled. Запись0, capture сохранён. -Это проверка доставки отключения; отсутствие wake после ручного Power-off -нужно проверить отдельно. После опыта вернуть настройку восстановления. +После ручного Power-off с оставленным USB CANCEL01-OBSERVE получил 40 samples +за 160.5s без возврата X4 в SDK, с disabled/preview_wanted=false. D455 сохранила +session и поток; независимое чтение Ubuntu не видит SDK/Mass Storage X4, +BlueZ ActiveInstances0 в конечной точке. Это ограниченная проверка отмены, +не измерение потребления или непрерывной radio-активности. После этого камера оставалась выключенной для MANUAL01; позже кнопка +Core/Node вернула SDK. Recovery остаётся выключенным. +Она не меняет настройку восстановления и не запускает preview. diff --git a/docs/node/16_INSTA360_X4_MANUAL_WAKE.md b/docs/node/16_INSTA360_X4_MANUAL_WAKE.md new file mode 100644 index 0000000..2c29cb0 --- /dev/null +++ b/docs/node/16_INSTA360_X4_MANUAL_WAKE.md @@ -0,0 +1,85 @@ +# X4 — разовое включение из списка устройств + +MANUAL01 / B16, 10.09.2026. Владелец запросил кнопку рядом с настройками и +просмотром в списке устройств Core и Node. X4 после CANCEL01 остаётся выключенной, +USB подключён; физическое Power для следующего теста больше не требуется. + +## Продуктовое размещение + +Задача оператора — включить известную камеру удалённо после завершённого цикла +работы. Сущность и полномочие принадлежат одному stable device_id на выбранном +борту. Используется существующая строка ResourceRow и Button «Включить»; +отдельный экран или действие только внутри просмотра не дают запрошенного +доступа из списка. Новый workspace/navigation/визуальный компонент не вводится. +Применены DG ResourceRow.actions, Button.loading, ToastStack и текущие токены; +DG8c53f73 не меняется. Платформенная SensorUiContribution.rowActions содержит +типизированные действия плагина; Core и Node используют один SensorWorkspace. + +Кнопка доступна при свежей связи с Node, подготовленном/enrolled устройстве, +offline X4 и сохранённой identity для wake. После подтверждённого online кнопка полностью скрывается: включённой камере +повторное включение не требуется (уточнение владельца10.09). При следующем +offline она возвращается; при неизвестной identity действие недоступно. Лоудер находится только внутри +нажатой кнопки, повтор блокируется; соседние несовместимые действия disabled. +Подготовленные устройства используют compact StatusBadge indicator во всех +состояниях: leading-slot согласно DG допускает именно компактную лампу, полный +статус находится в aria-label/title. Это сохраняет ширину названия с новой кнопкой +в обычном узком окне. Не настроенные устройства сохраняют полный trailing badge. +Ошибка завершает ожидание и показывается через общий ToastStack. Строковый +ProgressBar остаётся только для многошаговой prepare, не дублирует button loading. + +## Контракт и граница полномочий + +`power.wake` принимает пустые parameters и обычный OperationRequest с точным +device/session/idempotency/deadline. Durable receipt создаётся до эффекта; +повтор operation_id не повторяет команду, результат failure не запускает retry. +Broker использует отдельный private `/wake-once`; клиент не передаёт serial, +Bluetooth MAC, payload, shell или путь. Один вызов ограничен существующим +20s advertisement/45s parent watchdog. После beacon broker продолжает только +read-only наблюдение supervisor до52s от начала команды: холодная X4 в MANUAL01 +вернулась через34s SDK/37s Core. Повторной радиокоманды нет. Он сохраняет automatic recovery policy +и preview intent, не выполняет preview START, запись, фото, settings или Power-off. +Только подтверждённый возврат полного USB SDK identity даёт complete. +Node задаёт power.wake timeout55s в model ActionTimeouts; отдельная копия HTTP +client сохраняет прежние25s inventory/остальных действий, context deadline +запроса остаётся верхней границей. Regression проверяет фактический deadline +на входе driver transport и неизменность shared client. + +Root helper `/registry` запоминает полные serials непосредственно из однозначных +OS USB bindings, по умолчанию enabled=false. Это часть работающего установленного +модуля после prepare, не включение автостратегии. Прежние serials сохраняются +после исчезновения USB и upgrade. В broker/public inventory выходят только +stable IDs и wake_available; root-only serials и raw reports не публикуются. +Несколько экземпляров независимы; известные suffix collisions отклоняются. +Manual и automatic wake используют один adapter lock и общий persisted3/10min +лимит; manual не сбрасывает бюджет. Включённое на камере Bluetooth Wakeup и +оставленный USB — необходимые условия; их наличие не имитируется программно. + +## Доставка и приёмка + +B16 model0.1.3-8 и Node0.8.21 квалифицированы и установлены при выключенной X4. +MANUAL01 показал реальный wake с сохранённой policy и preview0/recording0, но +ранний error receipt: SDK вернулся после20s окна beacon. B17 model0.1.3-9 +добавляет ожидание до52s без повторной команды, Node0.8.21-3 объединяет его с компактной строкой и скрытием кнопки при online. +Промежуточные0.8.21-1/-2 не установлены; N14 прошёл полную квалификацию. +Владелец отдельно подтвердил ручное включение через приложение Node0.8.21; +readback подтвердил новую SDK session, recovery=false, preview0/recording0. +N15 Node0.8.21-3/Core прошёл DG/Node/Go race/Core architecture/typecheck, +821 Core tests и builds. Core активирован на8000 с сохранением identities; +реальная online X4 не имеет кнопки в normal/expanded, settings Escape работает. +P15 штатно установил Node0.8.21-3 и обновил X4 до0.1.3-9 через bundled +profile за27.64s. D455 восстановлена, X4 online/idle/recoveryfalse. +MANUAL02 ожидает Power-off владельцем и один новый UI click; аппаратное +подтверждение исправленного позднего receipt ещё pending. +Квалификация выполнялась на той же Ubuntu Mini через versioned builders. Никаких ad-hoc OS изменений. Node owner installer обновляет +уже установленный X4 model через свой fixed bundled profile, даже при выключенной +камере. На чистом Node без optional model первая подготовка остаётся в приложении. +Установка не заявляет SDK/image verification: это отдельное действие после wake. +Профиль сохраняет проверки hash/dependencies/lifecycle и не прерывает APT/dpkg. + +MANUAL01 подтвердил wake, но не корректное завершение операции. Повторная +MANUAL02 на B17 остаётся необходимой для исправленного подтверждения. +Проверить: installer upgrade при X4 offline; кнопка Core в offline/pending/online; +разовый wake при recovery=false, новый SDK instance session, policy остаётся false, +без preview/SD replay; явный просмотр отдельно; D455 и identities сохранены. +Все versions/UTC/monotonic/hashes/results — в ledger07. Clean Ubuntu и полная +power-off команда камеры остаются отдельными открытыми проверками/реализациями. diff --git a/packages/sensor-ui/src/SensorRowActions.tsx b/packages/sensor-ui/src/SensorRowActions.tsx new file mode 100644 index 0000000..413c306 --- /dev/null +++ b/packages/sensor-ui/src/SensorRowActions.tsx @@ -0,0 +1,13 @@ +import {Button} from '@nodedc/ui-react'; +import type {Sensor} from './contracts'; +import type {SensorRowAction} from './extensions'; + +export function SensorRowActions({device,actions=[],enabled,busy,pending,perform}:{ + device:Sensor; actions?:readonly SensorRowAction[]; enabled:boolean; busy:boolean; + pending?:string; perform:(action:string,parameters?:Record)=>void; +}) { + return <>{actions.filter(value=>actions.filter(other=>other.actionId===value.actionId).length===1).map(value=> + )}; +} diff --git a/packages/sensor-ui/src/SensorWorkspace.tsx b/packages/sensor-ui/src/SensorWorkspace.tsx index 98bea88..46b2907 100644 --- a/packages/sensor-ui/src/SensorWorkspace.tsx +++ b/packages/sensor-ui/src/SensorWorkspace.tsx @@ -2,6 +2,7 @@ import {useCallback,useEffect,useRef,useState} from 'react'; import {LoadingRegion,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react'; import {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts'; import {SensorDetail} from './SensorDetail'; +import {SensorRowActions} from './SensorRowActions'; import {SensorPreparations} from './SensorPreparations'; import {devicePreparation,preparationProgress} from './preparation'; import {sensorStatus} from './sensorStatus'; @@ -11,7 +12,7 @@ import './sensors.css'; import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow'; export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[]}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[]}){ const [adding,setAdding]=useState(false); - const [inventory,setInventory]=useState(null);const [selected,setSelected]=useState(null);const [editing,setEditing]=useState(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState>(()=>new Set());const activeActions=useRef(new Set());const [error,setError]=useState('');const [fresh,setFresh]=useState(false); + const [inventory,setInventory]=useState(null);const [selected,setSelected]=useState(null);const [editing,setEditing]=useState(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState>(()=>new Map());const activeActions=useRef(new Map());const [error,setError]=useState('');const [fresh,setFresh]=useState(false); const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]); const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]); useEffect(()=>{ @@ -25,10 +26,10 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer },[transport,enabled,refresh]); async function action(device:Sensor,action:string,parameters:Record={}){ if(activeActions.current.has(device.id))return; - activeActions.current.add(device.id);setBusy(new Set(activeActions.current));setError(''); + activeActions.current.set(device.id,action);setBusy(new Map(activeActions.current));setError(''); try{await perform(transport,device,action,parameters);await refresh();setEditing(current=>current?.id===device.id?null:current);} catch(e){failure(e);} - finally{activeActions.current.delete(device.id);setBusy(new Set(activeActions.current));} + finally{activeActions.current.delete(device.id);setBusy(new Map(activeActions.current));} } async function completeEnrollment(sessionId:string,signal?:AbortSignal){ if(!enabled)throw new Error('БК недоступен. Обновите состояние устройства.'); @@ -46,14 +47,15 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer return
{device?Detail?setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:
:<>
{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}
{transport.enrollment&&wirelessContributions(contributions).length>0&&setAdding(true)}>}{void refresh();}}>
{!inventory?:connected.length===0?:{connected.map(item=>{ - const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy.has(item.id); + const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id); + const pending=localBusy.get(item.id)??operation?.action_id; const configured=item.configured??item.snapshot.enrollment==='enrolled'; const prep=devicePreparation(inventory,item); const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label; - return
  • } title={item.name} description={item.model} metadata={{item.connection_label||`USB ${item.usb}`}} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?preparationProgress(prep,label):undefined} status={{configured&&item.online?null:label}} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&void action(item,'prepare')}>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&{setEditing(item);setName(item.name);}}>}setSelected(item.id)}>}/>
  • ;})}
    } + return
  • } title={item.name} description={item.model} metadata={{item.connection_label||`USB ${item.usb}`}} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={{configured?null:label}} actions={<>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&void action(item,'prepare')}>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&{setEditing(item);setName(item.name);}}>}setSelected(item.id)}>}/>
  • ;})}} } - setEditing(null)} footer={}>
    setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить}/>}
    + setEditing(null)} footer={}>
    setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить}/>}
    {adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>} setError('')}/>
    ; diff --git a/packages/sensor-ui/src/extensions.ts b/packages/sensor-ui/src/extensions.ts index 22fc0ef..4bd0917 100644 --- a/packages/sensor-ui/src/extensions.ts +++ b/packages/sensor-ui/src/extensions.ts @@ -21,10 +21,16 @@ export interface SensorUiContribution { retainOffline:boolean; supportsPreparation:boolean; supportsRenaming?:boolean; + rowActions?:(device:Sensor)=>readonly SensorRowAction[]; status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'}; wirelessEnrollment?:{label:string;View:ComponentType}; } +export interface SensorRowAction { + actionId:string; label:string; description?:string; disabled?:boolean; + parameters?:Record; +} + export function wirelessContributions(contributions:readonly SensorUiContribution[]):SensorUiContribution[] { return contributions.filter(value=>value.wirelessEnrollment&&contributions.filter(other=>other.kind===value.kind).length===1); } diff --git a/plugins/insta360-x4/frontend/src/model.ts b/plugins/insta360-x4/frontend/src/model.ts index 681dcc9..9fa9102 100644 --- a/plugins/insta360-x4/frontend/src/model.ts +++ b/plugins/insta360-x4/frontend/src/model.ts @@ -1,7 +1,16 @@ import type {Sensor} from '../../../../packages/sensor-ui/src/contracts'; +import type {SensorRowAction} from '../../../../packages/sensor-ui/src/extensions'; export interface RecoveryStatus { supported:boolean; enabled:boolean; preview_wanted:boolean; phase:string; + wake_available?:boolean; +} + +export function cameraRowActions(device:Sensor):readonly SensorRowAction[] { + if(device.online||!cameraStatus(device).recovery?.supported)return []; + return [{actionId:'power.wake',label:'Включить', + description:'На X4 должно быть включено «Пробуждение по Bluetooth». USB оставьте подключённым.', + disabled:!device.prepared||!(device.configured??device.snapshot.enrollment==='enrolled')||cameraStatus(device).recovery?.wake_available!==true}]; } export interface CameraStatus { diff --git a/plugins/insta360-x4/frontend/src/plugin.ts b/plugins/insta360-x4/frontend/src/plugin.ts index 3e7c649..a7e57b2 100644 --- a/plugins/insta360-x4/frontend/src/plugin.ts +++ b/plugins/insta360-x4/frontend/src/plugin.ts @@ -1,8 +1,9 @@ import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions'; import {X4Detail} from './X4Detail'; -import {cameraLabel} from './model'; +import {cameraLabel,cameraRowActions} from './model'; export const insta360X4SensorUi:SensorUiContribution={ kind:'insta360.x4',Detail:X4Detail,icon:'camera',retainOffline:true, supportsPreparation:true,supportsRenaming:true,status:cameraLabel, + rowActions:cameraRowActions, }; diff --git a/plugins/insta360-x4/packaging/build_deb.py b/plugins/insta360-x4/packaging/build_deb.py index f990bcc..9cffacb 100644 --- a/plugins/insta360-x4/packaging/build_deb.py +++ b/plugins/insta360-x4/packaging/build_deb.py @@ -16,7 +16,7 @@ sys.path.insert(0, str(REPOSITORY / "scripts/packaging")) from debian import package # noqa: E402 from fetch_sdk import verify # noqa: E402 -VERSION = "0.1.3-7" +VERSION = "0.1.3-9" WHEELS = { "aiohappyeyeballs", "aiohttp", diff --git a/plugins/insta360-x4/packaging/build_package_artifact.py b/plugins/insta360-x4/packaging/build_package_artifact.py index f99135b..b90d244 100644 --- a/plugins/insta360-x4/packaging/build_package_artifact.py +++ b/plugins/insta360-x4/packaging/build_package_artifact.py @@ -34,6 +34,7 @@ def build(): paths += list((REPOSITORY / "packages/plugin-sdk/python/missioncore_plugin_sdk").rglob("*.py")) paths += [REPOSITORY / "scripts/packaging/debian.py"] paths += [REPOSITORY / "apps/node-agent/packaging/insta360_profile.py"] + paths += [REPOSITORY / "apps/node-agent/packaging/install_owner_release.py"] paths += [ REPOSITORY / name for name in ( diff --git a/plugins/insta360-x4/packaging/wake_service.py b/plugins/insta360-x4/packaging/wake_service.py index 6462563..b7d0a93 100644 --- a/plugins/insta360-x4/packaging/wake_service.py +++ b/plugins/insta360-x4/packaging/wake_service.py @@ -47,6 +47,28 @@ class WakeService: def save(self, ident, value): write(self.root / (ident + ".json"), json.dumps(value).encode(), 0o600) + def registry(self): + """Remember exact USB identities without enabling any recovery policy.""" + with self.lock: + observed = connected() + known = { + p.stem for p in self.root.glob("instax4_*.json") if IDENTIFIER.fullmatch(p.stem) + } + for binding in observed: + ident = binding.device_id + if ( + sum(x.device_id == ident for x in observed) != 1 + or not re.fullmatch(r"[A-Za-z0-9]{7,64}", binding.serial) + or (ident not in known and len(known) >= 500) + ): + continue + value = self.load(ident) + if "serial" not in value: + value["serial"] = binding.serial + self.save(ident, value) + known.add(ident) + return {"items": sorted(ident for ident in known if self.load(ident).get("serial"))} + def control(self, ident, enabled): with self.lock: value = self.load(ident) @@ -65,20 +87,20 @@ class WakeService: self.running[ident].terminate() return {"enabled": enabled} - def wake(self, ident, attempt): + def wake(self, ident, attempt, manual=False): if not self.advertiser.acquire(blocking=False): return {"state": "busy"} try: # Installation cannot replace code/state while a wake is in flight. with acquisition(): - return self.wake_locked(ident, attempt) + return self.wake_locked(ident, attempt, manual) finally: self.advertiser.release() - def wake_locked(self, ident, attempt): + def wake_locked(self, ident, attempt, manual=False): with self.lock: value = self.load(ident) - if not value["enabled"] or "serial" not in value: + if (not manual and not value["enabled"]) or "serial" not in value: return {"state": "disabled"} serial = value["serial"] if any(x.device_id == ident for x in connected()): @@ -135,13 +157,15 @@ class WakeService: state = "connected" except (ValueError, OSError): pass - return {"state": state if self.load(ident)["enabled"] else "disabled"} + return {"state": state if manual or self.load(ident)["enabled"] else "disabled"} def dispatch(self, method, route, value, _headers): if method == "GET" and route == "/health": return {"ready": True} if method != "POST" or not isinstance(value, dict): raise ValueError("Unsupported recovery request") + if route == "/registry" and not value: + return self.registry() ident = value.get("device_id") if not isinstance(ident, str) or not IDENTIFIER.fullmatch(ident): raise ValueError("Invalid recovery identity") @@ -149,12 +173,12 @@ class WakeService: if type(value["enabled"]) is not bool: raise ValueError("Recovery requires an explicit boolean") return self.control(ident, value["enabled"]) - if route == "/wake" and set(value) == {"device_id", "attempt"}: + if route in ("/wake", "/wake-once") and set(value) == {"device_id", "attempt"}: if not isinstance(value["attempt"], str) or not re.fullmatch( r"[0-9a-f]{32}", value["attempt"] ): raise ValueError("Invalid wake attempt") - return self.wake(ident, value["attempt"]) + return self.wake(ident, value["attempt"], manual=route == "/wake-once") raise ValueError("Unsupported recovery request") diff --git a/plugins/insta360-x4/runtime/broker.py b/plugins/insta360-x4/runtime/broker.py index b859387..2400ef1 100644 --- a/plugins/insta360-x4/runtime/broker.py +++ b/plugins/insta360-x4/runtime/broker.py @@ -223,7 +223,7 @@ class Broker: return {"items": [self.item(item, node) for item in snapshots]} if method == "POST" and route == "/operation": identifier = value["session"]["device_id"] - if value.get("action_id") == "recovery.configure" and self.recovery: + if value.get("action_id") in ("recovery.configure", "power.wake") and self.recovery: return self.recovery.operation(identifier, value) if value.get("action_id") == "preview.stop" and self.recovery: try: diff --git a/plugins/insta360-x4/runtime/errors.py b/plugins/insta360-x4/runtime/errors.py index 6776924..417a242 100644 --- a/plugins/insta360-x4/runtime/errors.py +++ b/plugins/insta360-x4/runtime/errors.py @@ -1,6 +1,14 @@ """Public messages are allowlisted; vendor error text never crosses the boundary.""" MESSAGES = { + "wake_adapter_busy": "Сейчас включается другая камера. Повторите действие позже.", + "wake_identity_missing": "Сначала подключите эту камеру по USB и подготовьте её на борту.", + "wake_identity_conflict": "Не удалось однозначно выбрать камеру для включения.", + "wake_rate_limited": "Лимит попыток включения исчерпан. Повторите через 10 минут.", + "wake_not_confirmed": ( + "Камера не подключилась. Проверьте питание, USB " + "и настройку «Пробуждение по Bluetooth» на X4." + ), "unsupported_camera_parameter": "Параметр или действие недоступны в текущем режиме камеры.", "verification_recording_active": "Для проверки изображения остановите запись на камере.", "preview_no_decodable_image": "Камера не передала декодируемое изображение.", diff --git a/plugins/insta360-x4/runtime/native.py b/plugins/insta360-x4/runtime/native.py index 32b2e3d..0028b57 100644 --- a/plugins/insta360-x4/runtime/native.py +++ b/plugins/insta360-x4/runtime/native.py @@ -35,6 +35,10 @@ class VideoHeader(ctypes.Structure): def parameters(action, value): """Validate before any SDK call; parameters never select a file or library.""" + if action == "power.wake": + if not isinstance(value, dict) or value: + raise ValueError("Wake uses the enrolled camera identity only") + return 0, "", 0.0 if action == "recovery.configure": if ( not isinstance(value, dict) diff --git a/plugins/insta360-x4/runtime/recovery.py b/plugins/insta360-x4/runtime/recovery.py index b2b6f50..038d189 100644 --- a/plugins/insta360-x4/runtime/recovery.py +++ b/plugins/insta360-x4/runtime/recovery.py @@ -26,6 +26,7 @@ class Recovery: self.absent = {} self.stable = {} self.jobs = {} + self.wakeable = set() self.stopping = threading.Event() self.pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="x4-recovery") for path in sorted(root.glob("instax4_*.json"))[:500]: @@ -62,8 +63,50 @@ class Recovery: "enabled": state.get("enabled", False), "preview_wanted": state.get("preview_wanted", False), "phase": state.get("phase", "disabled"), + "wake_available": ident in self.wakeable, } + def sync_registry(self): + values = request(HELPER, "/registry", {}, timeout=4)["items"] + if not isinstance(values, list) or len(values) > 500: + raise ValueError("Invalid wake registry") + for ident in values: + self.broker.validate_identifier(ident) + with self.lock: + self.wakeable = set(values) + for ident in values: + if ident not in self.states: + self.states[ident] = self.default() + self.save(ident, self.states[ident]) + + def wake_once(self, ident, command): + # Explicit operator action. Keep the automatic policy and preview intent intact. + deadline = time.monotonic() + 52 + result = request( + HELPER, + "/wake-once", + {"device_id": ident, "attempt": command["operation_id"][3:]}, + timeout=52, + ) + state = result.get("state") + # A cold X4 can enumerate after the 20-second beacon has already ended. + # Observe the installed supervisor only; never send a second wake beacon. + if state == "unavailable": + while time.monotonic() < deadline and not self.stopping.is_set(): + if any(x["id"] == ident and x.get("online") for x in self.broker.snapshots()): + state = "connected" + break + self.stopping.wait(1) + if state == "connected": + return {"state": "complete", "result": {"usb_connected": True}} + errors = { + "busy": "wake_adapter_busy", + "disabled": "wake_identity_missing", + "identity_conflict": "wake_identity_conflict", + "exhausted": "wake_rate_limited", + } + return {"state": "error", "error": errors.get(state, "wake_not_confirmed")} + def snapshot(self, ident): with self.lock: current = self.current.get(ident) @@ -246,6 +289,11 @@ class Recovery: while not self.stopping.wait(2): try: snapshots = self.broker.snapshots() + try: + self.sync_registry() + except (OSError, ValueError, RuntimeError, KeyError, TypeError): + with self.lock: + self.wakeable = set() with self.lock: for ident, future in list(self.jobs.items()): if future.done(): @@ -270,6 +318,8 @@ class RecoveryCalls: self.recovery, self.ident, self.command = recovery, ident, command def call(self, action, params): + if action == "power.wake": + return self.recovery.wake_once(self.ident, self.command) if action == "recovery.configure": return self.recovery.configure(self.ident, params["enabled"]) if action == "preview.stop": diff --git a/plugins/insta360-x4/tests/check_owner_update.py b/plugins/insta360-x4/tests/check_owner_update.py new file mode 100644 index 0000000..ae09b96 --- /dev/null +++ b/plugins/insta360-x4/tests/check_owner_update.py @@ -0,0 +1,88 @@ +"""Synthetic owner installer checks: no actual APT, systemd or camera calls.""" + +import json +import sys +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "apps/node-agent/packaging")) +import install_owner_release as owner # noqa: E402 + +PROFILE = {"version": "0.1.3-8", "revision": "a" * 24} + + +class OwnerModelUpdateTests(unittest.TestCase): + def test_clean_host_does_not_install_optional_model_implicitly(self): + run = Mock() + with patch.object(owner.subprocess, "run", return_value=Mock(returncode=1, stdout="")): + self.assertEqual(owner.update_installed_x4(run), {"state": "not-installed"}) + run.assert_not_called() + + def test_current_installed_model_is_not_restarted(self): + run = Mock(return_value=PROFILE["version"]) + with ( + patch.object( + owner.subprocess, + "run", + return_value=Mock(returncode=0, stdout="install ok installed"), + ), + patch.object(owner.Path, "read_text", return_value=json.dumps(PROFILE)), + ): + self.assertEqual(owner.update_installed_x4(run)["state"], "current") + self.assertEqual(run.call_count, 1) + + def test_offline_model_update_uses_only_fixed_profile_and_checks_result(self): + run = Mock(side_effect=["0.1.3-7", "", PROFILE["version"]]) + with ( + patch.object( + owner.subprocess, + "run", + return_value=Mock(returncode=0, stdout="install ok installed"), + ), + patch.object( + owner.Path, + "read_text", + side_effect=[ + json.dumps(PROFILE), + json.dumps({"state": "complete", "revision": PROFILE["revision"]}), + ], + ), + ): + result = owner.update_installed_x4(run) + self.assertEqual( + result, + { + "state": "updated", + "version": PROFILE["version"], + "camera_verification": "not-performed", + }, + ) + self.assertEqual( + run.call_args_list[1].args[1], + ["/usr/bin/systemctl", "start", "mission-core-node-insta360-x4-profile.service"], + ) + self.assertIsNone(run.call_args_list[1].kwargs["timeout"]) + + def test_stale_profile_completion_cannot_qualify_updated_model(self): + run = Mock(side_effect=["0.1.3-7", "", PROFILE["version"]]) + with ( + patch.object( + owner.subprocess, + "run", + return_value=Mock(returncode=0, stdout="install ok installed"), + ), + patch.object( + owner.Path, + "read_text", + side_effect=[ + json.dumps(PROFILE), + json.dumps({"state": "complete", "revision": "b" * 24}), + ], + ),self.assertRaises(RuntimeError) + ): + owner.update_installed_x4(run) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/insta360-x4/tests/check_recovery.py b/plugins/insta360-x4/tests/check_recovery.py index 1631077..0142238 100644 --- a/plugins/insta360-x4/tests/check_recovery.py +++ b/plugins/insta360-x4/tests/check_recovery.py @@ -1,5 +1,6 @@ """Synthetic recovery/privilege-boundary tests. No USB, Bluetooth or service changes.""" +import io import json import sys import tempfile @@ -149,6 +150,72 @@ class RecoveryTests(unittest.TestCase): with self.assertRaises(ValueError): parameters("recovery.configure", value) + def test_registry_retains_offline_wake_identity_without_automatic_intent(self): + with patch.object(recovery, "request", return_value={"items": [A, B]}): + self.value.sync_registry() + self.assertTrue(self.value.view(A)["wake_available"]) + self.assertFalse(self.value.view(A)["enabled"]) + self.assertEqual(self.value.observe([], 900), []) + self.assertEqual(len(self.value.augment([])), 2) + + def test_manual_wake_is_durable_once_and_does_not_enable_policy_or_preview(self): + self.value.states[A] = self.value.default() + session = self.value.states[A]["session_id"] + value = command(A, session, "power.wake") + with patch.object(recovery, "request", return_value={"state": "connected"}) as call: + self.assertEqual(self.value.operation(A, value)["state"], "complete") + self.assertEqual(self.value.operation(A, value)["state"], "complete") + call.assert_called_once_with( + recovery.HELPER, + "/wake-once", + { + "device_id": A, + "attempt": value["operation_id"][3:], + }, + timeout=52, + ) + self.assertFalse(self.value.view(A)["enabled"]) + self.assertFalse(self.value.view(A)["preview_wanted"]) + with self.assertRaises(ValueError): + parameters("power.wake", {"serial": "ARBITRARY"}) + + def test_manual_wake_failure_does_not_schedule_automatic_retry(self): + self.value.states[A] = self.value.default() + value = command(A, self.value.states[A]["session_id"], "power.wake") + with ( + patch.object(recovery, "request", return_value={"state": "unavailable"}) as call, + patch.object(recovery.time, "monotonic", side_effect=[0, 53]), + ): + result = self.value.operation(A, value) + self.assertEqual(self.value.operation(A, value), result) + self.assertEqual(result["state"], "error") + call.assert_called_once() + self.assertEqual(self.value.observe([], 1000), []) + + def test_manual_wake_waits_for_late_sdk_return_without_another_radio_command(self): + with ( + patch.object(recovery, "request", return_value={"state": "unavailable"}) as call, + patch.object(recovery.time, "monotonic", side_effect=[0, 22, 37]), + patch.object(self.value.stopping, "wait") as wait, + patch.object(self.broker, "snapshots", side_effect=[[snapshot(B)], [snapshot(A)]]), + ): + result = self.value.wake_once(A, command(A, "x4_one", "power.wake")) + self.assertEqual(result["state"], "complete") + call.assert_called_once() + wait.assert_called_once_with(1) + self.assertFalse(self.value.view(A)["enabled"]) + + def test_manual_wake_does_not_wait_for_other_camera_or_retry_after_deadline(self): + with ( + patch.object(recovery, "request", return_value={"state": "unavailable"}) as call, + patch.object(recovery.time, "monotonic", side_effect=[0, 22, 53]), + patch.object(self.value.stopping, "wait"), + patch.object(self.broker, "snapshots", return_value=[snapshot(B)]), + ): + result = self.value.wake_once(A, command(A, "x4_one", "power.wake")) + self.assertEqual(result["state"], "error") + call.assert_called_once() + class WakeServiceTests(unittest.TestCase): def setUp(self): @@ -231,6 +298,49 @@ class WakeServiceTests(unittest.TestCase): self.service.advertiser.release() self.assertEqual(list(self.root.iterdir()), []) + def test_registry_enrolls_usb_without_enabling_recovery_and_keeps_it_when_unplugged(self): + with patch.object(wake_service, "connected", return_value=[self.binding]): + self.assertEqual(self.service.registry(), {"items": [A]}) + self.assertFalse(self.service.load(A)["enabled"]) + self.assertEqual(self.service.load(A)["attempts"], []) + with patch.object(wake_service, "connected", return_value=[]): + self.assertEqual(self.service.registry(), {"items": [A]}) + + def test_registry_never_enrolls_ambiguous_usb_identity(self): + with patch.object(wake_service, "connected", return_value=[self.binding, self.binding]): + self.assertEqual(self.service.registry(), {"items": []}) + + def test_manual_wake_works_while_disabled_and_shares_the_persisted_attempt_budget(self): + self.enable() + self.service.control(A, False) + + def start(*args, **kwargs): + kwargs["stdout"].write(b'{"sdk_usb_returned": true}') + kwargs["stdout"].flush() + return Mock(stdin=io.BytesIO(), returncode=0) + + with ( + patch.object(wake_service, "connected", return_value=[]), + patch.object( + wake_service.subprocess, + "Popen", + side_effect=start, + ) as spawn, + ): + self.assertEqual(self.service.wake_locked(A, "1" * 32), {"state": "disabled"}) + self.assertEqual( + self.service.wake_locked(A, "1" * 32, manual=True), {"state": "connected"} + ) + self.assertEqual( + self.service.wake_locked(A, "1" * 32, manual=True), {"state": "already_attempted"} + ) + self.assertEqual( + self.service.wake_locked(B, "2" * 32, manual=True), {"state": "disabled"} + ) + spawn.assert_called_once() + self.assertFalse(self.service.load(A)["enabled"]) + self.assertEqual(len(self.service.load(A)["attempts"]), 1) + if __name__ == "__main__": unittest.main() diff --git a/src/k1link/fleet/sensors.py b/src/k1link/fleet/sensors.py index a138379..1e103f7 100644 --- a/src/k1link/fleet/sensors.py +++ b/src/k1link/fleet/sensors.py @@ -29,6 +29,7 @@ ACTIONS = { "settings.apply", "files.list", "recovery.configure", + "power.wake", } MAX_INVENTORY_ITEMS = 500 MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024