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"],