feat(x4): add per-camera manual wake and hide power action when connected

This commit is contained in:
DCCONSTRUCTIONS
2026-09-10 16:27:48 +03:00
parent db14ee5435
commit 99f29218d7
27 changed files with 992 additions and 126 deletions
+15 -1
View File
@@ -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);
});
@@ -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 {
@@ -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" })
}
+9
View File
@@ -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 {
+2 -2
View File
@@ -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
@@ -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"
}
@@ -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"],
@@ -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.95844411: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.22221312: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.25711212: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.84611412: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.84241912: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.00175712: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.73341812: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.28038613: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.33968613: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.34613813: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.45027513: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.99812013: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.
@@ -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.
## REC0102 / LIVE0809 — два последовательных цикла восстановления приняты
[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.
+49 -82
View File
@@ -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: CoreNode восстановили канал через 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 не означают сотни одновременных видеопотоков;
предел определяется измерениями и независимостью экземпляров.
+19 -9
View File
@@ -1,10 +1,13 @@
# X4 — восстановление подключения и просмотра
REC01 / B15, 10.09.2026. Продолжение принятого WAKE01. Штатно установлены
Node 0.8.20 и X4 0.1.3-7, общая UI активирована в Core. Настройка включена
через Core. REC0102/LIVE0809 приняли два последовательных 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. REC0102/LIVE0809 приняли два последовательных 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.
+85
View File
@@ -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 команда камеры остаются отдельными открытыми проверками/реализациями.
@@ -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<string,unknown>)=>void;
}) {
return <>{actions.filter(value=>actions.filter(other=>other.actionId===value.actionId).length===1).map(value=>
<Button key={value.actionId} aria-label={`${value.label}: ${device.name}`} title={value.description}
disabled={!enabled||busy||value.disabled} loading={pending===value.actionId}
onClick={()=>perform(value.actionId,value.parameters)}>{value.label}</Button>)}</>;
}
+8 -6
View File
@@ -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<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlySet<string>>(()=>new Set());const activeActions=useRef(new Set<string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());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<string,unknown>={}){
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 <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
{!inventory?<LoadingRegion loading label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{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 <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
<SensorPreparations inventory={inventory}/>
</>}
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button loading={localBusy.get(editing?.id??'')==='rename'} disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
</div>;
+6
View File
@@ -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<SensorEnrollmentProps>};
}
export interface SensorRowAction {
actionId:string; label:string; description?:string; disabled?:boolean;
parameters?:Record<string,unknown>;
}
export function wirelessContributions(contributions:readonly SensorUiContribution[]):SensorUiContribution[] {
return contributions.filter(value=>value.wirelessEnrollment&&contributions.filter(other=>other.kind===value.kind).length===1);
}
@@ -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 {
+2 -1
View File
@@ -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,
};
+1 -1
View File
@@ -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",
@@ -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 (
+31 -7
View File
@@ -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")
+1 -1
View File
@@ -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:
+8
View File
@@ -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": "Камера не передала декодируемое изображение.",
+4
View File
@@ -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)
+50
View File
@@ -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":
@@ -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()
+110
View File
@@ -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()
+1
View File
@@ -29,6 +29,7 @@ ACTIONS = {
"settings.apply",
"files.list",
"recovery.configure",
"power.wake",
}
MAX_INVENTORY_ITEMS = 500
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024