feat(x4): add per-camera manual wake and hide power action when connected
This commit is contained in:
@@ -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" })
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user