From a3c15e11e962fca5c93b5394ba9e041663e5b142 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 10 Sep 2026 09:21:24 +0300 Subject: [PATCH] Add packaged Insta360 X4 integration and recover paired Node channels Discover independent camera instances and prepare their versioned runtime from Node or remote Core. Add isolated SDK workers, camera controls, raw dual-fisheye WebRTC preview, and shared action/region loading states. Recover existing Node bindings over known Tailscale addresses after a Core LAN address change. Preserve identities and trust, pin both peers, migrate endpoints with revision checks, and require real heartbeats for online status. Fix the Python client certificate profile for Go X509 verification. Pin Design Guideline 8c53f73 and retain installer/build/acceptance history. Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending. Validation: qualified DG/Node builds and Go race tests; 31 fleet tests; Python-to-Go certificate interoperability and live tailnet recovery with five fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance. Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open. --- AGENTS.md | 34 ++ .../src/composition/devicePlugins.ts | 5 + .../core/device-plugins/DevicePluginHost.tsx | 6 +- .../src/core/device-plugins/registry.ts | 11 + apps/control-station/src/main.tsx | 4 +- .../src/workspaces/fleet/VehicleSensors.tsx | 2 +- .../workspaces/fleet/VehiclesWorkspace.tsx | 4 +- apps/control-station/test/insta360Ui.test.mjs | 29 ++ .../test/k1ManualControl.test.mjs | 9 +- .../test/k1SupervisorPresentation.test.mjs | 30 +- .../test/sensorPreparation.test.mjs | 34 ++ apps/node-agent/internal/node/pairing.go | 26 +- .../internal/node/pairing_recovery.go | 112 ++++ .../internal/node/pairing_recovery_test.go | 197 +++++++ .../internal/node/pairing_transport.go | 42 +- .../internal/node/sensor_events_test.go | 1 + .../node-agent/internal/node/sensor_models.go | 137 +++++ .../internal/node/sensor_models_test.go | 351 +++++++++++++ .../internal/node/sensor_preparation.go | 277 ++++++++++ apps/node-agent/internal/node/sensors.go | 313 +++++------ apps/node-agent/internal/node/sensors_test.go | 21 + .../50-mission-core-device-prepare.rules | 3 +- apps/node-agent/packaging/build.py | 2 +- apps/node-agent/packaging/build_deb.py | 20 +- .../packaging/build_linux_source.py | 128 +++++ .../packaging/build_owner_release.py | 72 +++ .../packaging/check_channel_certificates.py | 260 +++++++++ .../packaging/check_x4_profile_entry.py | 67 +++ .../packaging/diagnose_x4_profile.py | 138 +++++ .../packaging/fetch_linux_toolchains.py | 33 ++ .../packaging/insta360-profile.json | 8 + apps/node-agent/packaging/insta360_profile.py | 229 ++++++++ .../packaging/install-owner-release | 14 + .../packaging/install_owner_release.py | 296 +++++++++++ .../packaging/linux-toolchains.json | 18 + .../node-agent/packaging/linux_build_entry.py | 186 +++++++ apps/node-agent/packaging/linux_build_job.py | 359 +++++++++++++ ...sion-core-node-insta360-x4-profile.service | 10 + apps/node-agent/packaging/preinst | 4 + apps/node-agent/packaging/prerm | 4 + apps/node-agent/packaging/repack_node_n08.py | 100 ++++ apps/node-agent/packaging/repack_x4_bundle.py | 125 +++++ apps/node-agent/packaging/repack_x4_n09.py | 130 +++++ .../node-agent/packaging/repack_x4_profile.py | 108 ++++ apps/node-agent/packaging/resume_core_n08.py | 179 +++++++ apps/node-agent/packaging/resume_core_n09.py | 230 ++++++++ apps/node-agent/packaging/setup-monitor | 6 +- apps/node-agent/ui/src/NodeSensors.tsx | 3 +- apps/node-agent/ui/tsconfig.json | 3 + apps/node-agent/ui/vite.config.js | 2 +- docs/node/04_NODE_CORE_PAIRING_PROTOCOL.md | 65 ++- .../06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md | 197 +++++++ .../07_INSTA360_X4_INSTALLATION_LEDGER.md | 492 ++++++++++++++++++ docs/node/08_INSTA360_X4_SDK_SOURCE_AUDIT.md | 43 ++ .../09_INSTA360_X4_IMPLEMENTATION_STATUS.md | 85 +++ docs/node/10_INSTA360_X4_NEXT_STEPS.md | 44 ++ .../11_INSTA360_X4_AUTONOMY_AND_USB_POWER.md | 91 ++++ packages/sensor-ui/src/LiveViewport.tsx | 30 +- packages/sensor-ui/src/SensorDetail.tsx | 12 +- packages/sensor-ui/src/SensorPreparations.tsx | 15 + packages/sensor-ui/src/SensorWorkspace.tsx | 30 +- packages/sensor-ui/src/contracts.ts | 9 +- packages/sensor-ui/src/preparation.ts | 22 + packages/sensor-ui/src/sensorStatus.ts | 1 + .../frontend/src/CameraSettings.tsx | 19 + plugins/insta360-x4/frontend/src/X4Detail.tsx | 60 +++ plugins/insta360-x4/frontend/src/model.ts | 41 ++ plugins/insta360-x4/frontend/src/plugin.ts | 8 + plugins/insta360-x4/native/bridge.cpp | 429 +++++++++++++++ plugins/insta360-x4/native/bridge.h | 44 ++ plugins/insta360-x4/native/tests/check_abi.py | 211 ++++++++ plugins/insta360-x4/native/tests/fake_sdk.cpp | 129 +++++ .../packaging/50-mission-core-insta360.rules | 5 + .../packaging/70-mission-core-insta360.rules | 1 + plugins/insta360-x4/packaging/bootstrap.py | 32 ++ plugins/insta360-x4/packaging/build_deb.py | 230 ++++++++ plugins/insta360-x4/packaging/build_native.py | 99 ++++ .../packaging/build_native_entry.py | 9 + .../packaging/build_owner_release.py | 67 +++ .../packaging/build_package_artifact.py | 84 +++ .../packaging/build_source_artifact.py | 62 +++ .../packaging/check_package_entry.py | 84 +++ plugins/insta360-x4/packaging/fetch_sdk.py | 146 ++++++ plugins/insta360-x4/packaging/install | 14 + .../insta360-x4/packaging/install_release.py | 263 ++++++++++ plugins/insta360-x4/packaging/layout.py | 60 +++ .../mission-core-insta360-supervisor.service | 26 + .../packaging/mission-core-insta360.service | 31 ++ ...sion-core-node-insta360-x4-prepare.service | 8 + .../packaging/native_build_entry.py | 203 ++++++++ .../packaging/owner_release_entry.py | 149 ++++++ .../packaging/package_build_entry.py | 12 + plugins/insta360-x4/packaging/postinst | 16 + plugins/insta360-x4/packaging/postrm | 11 + plugins/insta360-x4/packaging/preinst | 5 + plugins/insta360-x4/packaging/prepare.py | 231 ++++++++ plugins/insta360-x4/packaging/prerm | 12 + .../insta360-x4/packaging/python-lock.json | 137 +++++ plugins/insta360-x4/packaging/sdk-lock.json | 114 ++++ plugins/insta360-x4/packaging/supervisor.py | 157 ++++++ plugins/insta360-x4/runtime/__init__.py | 1 + plugins/insta360-x4/runtime/broker.py | 270 ++++++++++ plugins/insta360-x4/runtime/errors.py | 12 + plugins/insta360-x4/runtime/frames.py | 191 +++++++ plugins/insta360-x4/runtime/http.py | 145 ++++++ plugins/insta360-x4/runtime/identity.py | 85 +++ plugins/insta360-x4/runtime/lifecycle.py | 24 + plugins/insta360-x4/runtime/media.py | 394 ++++++++++++++ plugins/insta360-x4/runtime/native.py | 179 +++++++ plugins/insta360-x4/runtime/operations.py | 105 ++++ plugins/insta360-x4/runtime/verification.py | 114 ++++ plugins/insta360-x4/runtime/worker.py | 154 ++++++ plugins/insta360-x4/tests/check_inventory.py | 75 +++ plugins/insta360-x4/tests/check_media.py | 413 +++++++++++++++ plugins/insta360-x4/tests/check_profile.py | 164 ++++++ plugins/insta360-x4/tests/check_runtime.py | 349 +++++++++++++ src/k1link/fleet/recovery.py | 162 ++++++ src/k1link/fleet/registry.py | 41 +- src/k1link/fleet/sensors.py | 18 +- src/k1link/fleet/transport.py | 9 +- src/k1link/fleet/trust.py | 27 +- tests/fleet/test_pairing.py | 192 +++++++ tests/test_insta360_control.py | 213 ++++++++ tests/test_insta360_native.py | 12 + tests/test_insta360_sdk_source.py | 91 ++++ 125 files changed, 11916 insertions(+), 251 deletions(-) create mode 100644 apps/control-station/test/insta360Ui.test.mjs create mode 100644 apps/control-station/test/sensorPreparation.test.mjs create mode 100644 apps/node-agent/internal/node/pairing_recovery.go create mode 100644 apps/node-agent/internal/node/pairing_recovery_test.go create mode 100644 apps/node-agent/internal/node/sensor_models.go create mode 100644 apps/node-agent/internal/node/sensor_models_test.go create mode 100644 apps/node-agent/internal/node/sensor_preparation.go create mode 100644 apps/node-agent/packaging/build_linux_source.py create mode 100644 apps/node-agent/packaging/build_owner_release.py create mode 100644 apps/node-agent/packaging/check_channel_certificates.py create mode 100644 apps/node-agent/packaging/check_x4_profile_entry.py create mode 100644 apps/node-agent/packaging/diagnose_x4_profile.py create mode 100644 apps/node-agent/packaging/fetch_linux_toolchains.py create mode 100644 apps/node-agent/packaging/insta360-profile.json create mode 100644 apps/node-agent/packaging/insta360_profile.py create mode 100644 apps/node-agent/packaging/install-owner-release create mode 100644 apps/node-agent/packaging/install_owner_release.py create mode 100644 apps/node-agent/packaging/linux-toolchains.json create mode 100644 apps/node-agent/packaging/linux_build_entry.py create mode 100644 apps/node-agent/packaging/linux_build_job.py create mode 100644 apps/node-agent/packaging/mission-core-node-insta360-x4-profile.service create mode 100644 apps/node-agent/packaging/repack_node_n08.py create mode 100644 apps/node-agent/packaging/repack_x4_bundle.py create mode 100644 apps/node-agent/packaging/repack_x4_n09.py create mode 100644 apps/node-agent/packaging/repack_x4_profile.py create mode 100644 apps/node-agent/packaging/resume_core_n08.py create mode 100644 apps/node-agent/packaging/resume_core_n09.py create mode 100644 docs/node/06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md create mode 100644 docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md create mode 100644 docs/node/08_INSTA360_X4_SDK_SOURCE_AUDIT.md create mode 100644 docs/node/09_INSTA360_X4_IMPLEMENTATION_STATUS.md create mode 100644 docs/node/10_INSTA360_X4_NEXT_STEPS.md create mode 100644 docs/node/11_INSTA360_X4_AUTONOMY_AND_USB_POWER.md create mode 100644 packages/sensor-ui/src/SensorPreparations.tsx create mode 100644 packages/sensor-ui/src/preparation.ts create mode 100644 plugins/insta360-x4/frontend/src/CameraSettings.tsx create mode 100644 plugins/insta360-x4/frontend/src/X4Detail.tsx create mode 100644 plugins/insta360-x4/frontend/src/model.ts create mode 100644 plugins/insta360-x4/frontend/src/plugin.ts create mode 100644 plugins/insta360-x4/native/bridge.cpp create mode 100644 plugins/insta360-x4/native/bridge.h create mode 100644 plugins/insta360-x4/native/tests/check_abi.py create mode 100644 plugins/insta360-x4/native/tests/fake_sdk.cpp create mode 100644 plugins/insta360-x4/packaging/50-mission-core-insta360.rules create mode 100644 plugins/insta360-x4/packaging/70-mission-core-insta360.rules create mode 100644 plugins/insta360-x4/packaging/bootstrap.py create mode 100644 plugins/insta360-x4/packaging/build_deb.py create mode 100644 plugins/insta360-x4/packaging/build_native.py create mode 100644 plugins/insta360-x4/packaging/build_native_entry.py create mode 100644 plugins/insta360-x4/packaging/build_owner_release.py create mode 100644 plugins/insta360-x4/packaging/build_package_artifact.py create mode 100644 plugins/insta360-x4/packaging/build_source_artifact.py create mode 100644 plugins/insta360-x4/packaging/check_package_entry.py create mode 100644 plugins/insta360-x4/packaging/fetch_sdk.py create mode 100644 plugins/insta360-x4/packaging/install create mode 100644 plugins/insta360-x4/packaging/install_release.py create mode 100644 plugins/insta360-x4/packaging/layout.py create mode 100644 plugins/insta360-x4/packaging/mission-core-insta360-supervisor.service create mode 100644 plugins/insta360-x4/packaging/mission-core-insta360.service create mode 100644 plugins/insta360-x4/packaging/mission-core-node-insta360-x4-prepare.service create mode 100644 plugins/insta360-x4/packaging/native_build_entry.py create mode 100644 plugins/insta360-x4/packaging/owner_release_entry.py create mode 100644 plugins/insta360-x4/packaging/package_build_entry.py create mode 100644 plugins/insta360-x4/packaging/postinst create mode 100644 plugins/insta360-x4/packaging/postrm create mode 100644 plugins/insta360-x4/packaging/preinst create mode 100644 plugins/insta360-x4/packaging/prepare.py create mode 100644 plugins/insta360-x4/packaging/prerm create mode 100644 plugins/insta360-x4/packaging/python-lock.json create mode 100644 plugins/insta360-x4/packaging/sdk-lock.json create mode 100644 plugins/insta360-x4/packaging/supervisor.py create mode 100644 plugins/insta360-x4/runtime/__init__.py create mode 100644 plugins/insta360-x4/runtime/broker.py create mode 100644 plugins/insta360-x4/runtime/errors.py create mode 100644 plugins/insta360-x4/runtime/frames.py create mode 100644 plugins/insta360-x4/runtime/http.py create mode 100644 plugins/insta360-x4/runtime/identity.py create mode 100644 plugins/insta360-x4/runtime/lifecycle.py create mode 100644 plugins/insta360-x4/runtime/media.py create mode 100644 plugins/insta360-x4/runtime/native.py create mode 100644 plugins/insta360-x4/runtime/operations.py create mode 100644 plugins/insta360-x4/runtime/verification.py create mode 100644 plugins/insta360-x4/runtime/worker.py create mode 100644 plugins/insta360-x4/tests/check_inventory.py create mode 100644 plugins/insta360-x4/tests/check_media.py create mode 100644 plugins/insta360-x4/tests/check_profile.py create mode 100644 plugins/insta360-x4/tests/check_runtime.py create mode 100644 src/k1link/fleet/recovery.py create mode 100644 tests/test_insta360_control.py create mode 100644 tests/test_insta360_native.py create mode 100644 tests/test_insta360_sdk_source.py diff --git a/AGENTS.md b/AGENTS.md index aa2f23a..eb97e6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,40 @@ and the boundary between Mission Core and vendor-specific integration code. - Synthetic or explicitly redacted fixtures may be committed under `tests/fixtures/`. +## Insta360 and clean-host installation — owner requirement, 2026-09-08 + +- The current X4 starting point is USB enumeration only. SDK installation, + camera initialization and live video have not been accepted. +- Every Ubuntu change needed for Insta360 must execute through the shipped + installer or the application's versioned device-preparation workflow from + the first board experiment. Do not repair the board with an ad-hoc apt/pip + install, copied library, chmod, environment override, service edit or root + invocation and promise to package it later. Fix the product artifact first, + then rerun that artifact. Read-only engineering inspection remains allowed. +- Document every relevant action and its before/after evidence in + `docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md`; record the artifact version, + owning installer step, dependencies, idempotency, failure behavior and rollback. + Keep private logs/identifiers/media out of Git and redact their public summary. +- A working prepared Mini is not clean-Ubuntu acceptance. Qualify dependency + closure on the declared clean OS image and actual USB operation separately; + installer, preparation and GUI acceptance must use the same shipped code. +- X4 is operator live video and camera control (settings, photo, recording + start/stop and file access), per the owner’s subsequent scope expansion. + Preview and recording are separate operations. No stitching, AI, navigation + or vehicle-control integration is admitted. Model support and physical device instances are separate: several + cameras of the same model must retain independent identity and lifecycle. +- Follow the scoped implementation plan in + `docs/node/06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md`. It is a proposed plan, + not evidence that SDK compatibility, hardware capacity or installation passed. +- Subsequent owner direction: the existing Ubuntu Mini is the available build + and qualification machine; do not depend on Worker 006 or another host for + this X4 work. Its existing GCC may compile through a versioned build artifact + in bounded temporary staging. This is a build step, not runtime installation: + no system changes, SDK execution or camera access during compilation. The + final installer must carry the compiled payload so a clean operator Ubuntu + does not inherit an undeclared compiler/build-cache prerequisite. All board + staging/build/install/test actions remain documented and artifact-owned. + ## Implementation order Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy diff --git a/apps/control-station/src/composition/devicePlugins.ts b/apps/control-station/src/composition/devicePlugins.ts index 8e77533..2091dcb 100644 --- a/apps/control-station/src/composition/devicePlugins.ts +++ b/apps/control-station/src/composition/devicePlugins.ts @@ -1,8 +1,13 @@ import type { DeviceUiPlugin } from "../core/device-plugins/contracts"; import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin"; +import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin'; // Composition root: this is the only place where Mission Core chooses which // statically reviewed device plugins are shipped in the current build. export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([ xgridsK1Plugin, ]); + +// Node-executed camera controls use the existing sensor contribution contract; +// they do not register a desktop capture/AI runtime or a new product workspace. +export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]); diff --git a/apps/control-station/src/core/device-plugins/DevicePluginHost.tsx b/apps/control-station/src/core/device-plugins/DevicePluginHost.tsx index 1a843aa..5a30083 100644 --- a/apps/control-station/src/core/device-plugins/DevicePluginHost.tsx +++ b/apps/control-station/src/core/device-plugins/DevicePluginHost.tsx @@ -11,6 +11,8 @@ import { import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext"; import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts"; import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry"; +import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions'; +const NO_NODE_SENSORS: readonly SensorUiContribution[] = Object.freeze([]); interface DevicePluginHostValue { registry: DevicePluginRegistry; @@ -83,12 +85,14 @@ export function commitPersistedDeviceModelId( export function DevicePluginHostProvider({ plugins, + nodeSensorContributions = NO_NODE_SENSORS, children, }: { plugins: readonly DeviceUiPlugin[]; + nodeSensorContributions?: readonly SensorUiContribution[]; children: ReactNode; }) { - const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]); + const registry = useMemo(() => createDevicePluginRegistry(plugins, nodeSensorContributions), [plugins, nodeSensorContributions]); const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []); const [selectedModelId, setSelectedModelId] = useState(() => restorePersistedDeviceModelId(registry, selectionStorage) diff --git a/apps/control-station/src/core/device-plugins/registry.ts b/apps/control-station/src/core/device-plugins/registry.ts index 1429d2d..8da5b82 100644 --- a/apps/control-station/src/core/device-plugins/registry.ts +++ b/apps/control-station/src/core/device-plugins/registry.ts @@ -6,8 +6,10 @@ import { type DeviceUiPlugin, type RegisteredDeviceModel, } from "./contracts"; +import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions'; export interface DevicePluginRegistry { + readonly sensorContributions: readonly SensorUiContribution[]; readonly plugins: readonly DeviceUiPlugin[]; readonly models: readonly RegisteredDeviceModel[]; resolveModel: (modelId: string) => RegisteredDeviceModel | null; @@ -15,6 +17,7 @@ export interface DevicePluginRegistry { export function createDevicePluginRegistry( installedPlugins: readonly DeviceUiPlugin[], + nodeSensorContributions: readonly SensorUiContribution[] = [], ): DevicePluginRegistry { const pluginIds = new Set(); const modelIds = new Set(); @@ -133,7 +136,15 @@ export function createDevicePluginRegistry( } const modelById = new Map(models.map((registered) => [registered.model.id, registered])); + const sensorContributions = [ + ...installedPlugins.flatMap(plugin => plugin.sensorUi?.contributions ?? []), + ...nodeSensorContributions, + ]; + if (new Set(sensorContributions.map(value => value.kind)).size !== sensorContributions.length) { + throw new Error('Плагины повторяют модель устройства БК.'); + } return { + sensorContributions: Object.freeze(sensorContributions), plugins: Object.freeze([...installedPlugins]), models: Object.freeze(models), resolveModel: (modelId) => modelById.get(modelId) ?? null, diff --git a/apps/control-station/src/main.tsx b/apps/control-station/src/main.tsx index dcf0203..a486dcf 100644 --- a/apps/control-station/src/main.tsx +++ b/apps/control-station/src/main.tsx @@ -6,7 +6,7 @@ import "@nodedc/tokens/themes.css"; import "@nodedc/ui-core/styles.css"; import App from "./App"; -import { installedDevicePlugins } from "./composition/devicePlugins"; +import { installedDevicePlugins, installedNodeSensorContributions } from "./composition/devicePlugins"; import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost"; import { ComputeContourProvider } from "./core/system/ComputeContourContext"; import "./styles.css"; @@ -23,7 +23,7 @@ applyNodedcTheme(rootElement, { theme: "dark" }); createRoot(rootElement).render( - + diff --git a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx index 6686905..3a29aad 100644 --- a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx +++ b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx @@ -6,7 +6,7 @@ import type {SensorInventory,SensorTransport} from '../../../../../packages/sens import {fleetRequest} from '../../core/fleet/useFleet'; export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){ const {registry}=useDevicePluginHost(); - const sensorContributions=useMemo(()=>registry.plugins.flatMap(plugin=>plugin.sensorUi?.contributions??[]),[registry]); + const sensorContributions=registry.sensorContributions; const transport=useMemo(()=>({ enrollment:{ state:()=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment`), diff --git a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx index b1a3787..1038fbe 100644 --- a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx +++ b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx @@ -1,6 +1,6 @@ import {BoardMonitorView} from './BoardMonitorView'; import { useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react"; +import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react"; import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet"; import "./fleet.css"; import { VehicleSensors } from "./VehicleSensors"; @@ -58,7 +58,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe {detail.enrollment !== "revoked" && } } - : !fleet.items ? : fleet.items.length === 0 ? : {fleet.items.map(item =>
  • } title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={{fleet.error ? "Нет свежих данных" : statusLabel(item)}} actions={ setSelected(item.id)}>} />
  • )}
    } + : !fleet.items ? : fleet.items.length === 0 ? : {fleet.items.map(item =>
  • } title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={{fleet.error ? "Нет свежих данных" : statusLabel(item)}} actions={ setSelected(item.id)}>} />
  • )}
    } {preview ? : }}>
    ({value,label:videoNames[value]??value}))} onChange={setLayer}/>
    {groups.map(group=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/>}
    - {detail.recordings?.length?detail.recordings.map(item=>
    {new Date(item.started_at).toLocaleString('ru-RU')}{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}void act('replay',{recording_id:item.id})}>
    ):

    Записей пока нет.

    }
    } +
    apply('function_mode',Number(next))}/> + {value.mode===7&&PHOTO_SIZES[size]).map(size=>({value:String(size),label:PHOTO_SIZES[size]}))} disabled={disabled} onChange={next=>apply('photo_size',Number(next))}/>} + {exposure.length>0&&apply('white_balance',Number(next))}/>} +
    ISO
    {value.values.iso||'Авто'}
    Выдержка
    {value.values.shutter_seconds>0?`${value.values.shutter_seconds.toFixed(4)} с`:'Авто'}
    ; +} diff --git a/plugins/insta360-x4/frontend/src/X4Detail.tsx b/plugins/insta360-x4/frontend/src/X4Detail.tsx new file mode 100644 index 0000000..5ee79a3 --- /dev/null +++ b/plugins/insta360-x4/frontend/src/X4Detail.tsx @@ -0,0 +1,60 @@ +import {useCallback,useEffect,useRef,useState} from 'react'; +import {LoadingRegion,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge} from '@nodedc/ui-react'; +import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions'; +import {perform} from '../../../../packages/sensor-ui/src/contracts'; +import {LiveViewport} from '../../../../packages/sensor-ui/src/LiveViewport'; +import {CameraSettings} from './CameraSettings'; +import {cameraStatus,fileName,type CameraFiles,type CameraSettings as Settings} from './model'; + +export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorDetailProps){ + const status=cameraStatus(device); + const [settings,setSettings]=useState(null); + const [files,setFiles]=useState(null); + const [pending,setPending]=useState(null);const busy=pending!==null; + const [settingsLoading,setSettingsLoading]=useState(false);const running=useRef(false);const mounted=useRef(true); + const available=enabled&&device.online&&status.connected; + useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]); + const load=useCallback(async()=>{ + if(status.function_mode===null)return; + if(mounted.current)setSettingsLoading(true); + try{const value=await perform(transport,device,'settings.read',{mode:status.function_mode});if(mounted.current)setSettings(value);} + finally{if(mounted.current)setSettingsLoading(false);} + },[transport,device.id,device.snapshot.context.session_id,status.function_mode]); + useEffect(()=>{if(available)void load().catch(error=>{if(mounted.current)failure(error);});},[available,load]); + async function action(name:string,parameters:Record={},key=name){ + if(running.current||!enabled||(!available&&name!=='refresh'))return; + running.current=true;setPending(key);failure(null); + try{ + const result=name==='refresh'?(available?await load():undefined):await perform(transport,device,name,parameters); + if(!mounted.current)return; + if(name==='settings.apply')setSettings(result as Settings); + if(name==='files.list')setFiles(result as CameraFiles); + if(name==='photo.capture'||name==='record.stop')setFiles(null); + await refresh(); + }catch(error){if(mounted.current)failure(error);} + finally{running.current=false;if(mounted.current)setPending(null);} + } + const mode=settings?.mode??status.function_mode; + const recordLabel=status.recording===1?'Идёт запись на карту':status.recording===0?'Запись остановлена':'Состояние записи не подтверждено'; + const viewDevice=available?device:{...device,snapshot:{...device.snapshot,acquisition:'idle'}}; + return
    + void action('refresh')}>}> + {!available&&

    Нет свежей связи с камерой.

    } +
    + {status.alerts?.temperature_high&&

    Камера сообщает о перегреве.

    } + {status.alerts?.storage_full&&

    На карте камеры закончилось место.

    } + {status.alerts?.battery_low&&

    Камера сообщает о низком заряде.

    } +
    + + {available?recordLabel:'Состояние недоступно'}}> +
    + {available&&status.recording===1&&

    Запись продолжится после закрытия просмотра.

    } +
    + + {settings?void action('settings.apply',{mode:settings.mode,key,value})}/>:available&&!settingsLoading?

    Настройки недоступны. Обновите состояние камеры.

    :null} +
    + void action('files.list',{offset:0},'files.refresh')}>Обновить список}> + {files?files.items.length?<>{files.items.map((item,index)=>
  • }/>
  • )}
    {files.offset+1}–{files.offset+files.items.length} из {files.total}
    :

    Файлов нет.

    :

    Обновите список, чтобы увидеть записи на карте камеры.

    } +
    +
    ; +} diff --git a/plugins/insta360-x4/frontend/src/model.ts b/plugins/insta360-x4/frontend/src/model.ts new file mode 100644 index 0000000..a997275 --- /dev/null +++ b/plugins/insta360-x4/frontend/src/model.ts @@ -0,0 +1,41 @@ +import type {Sensor} from '../../../../packages/sensor-ui/src/contracts'; + +export interface CameraStatus { + connected:boolean; preview:number|null; recording:number|null; function_mode:number|null; + firmware?:string; battery?:{level:number}; storage?:{free_bytes:number}; + alerts?:{battery_low?:boolean;storage_full?:boolean;temperature_high?:boolean}; +} +export interface CameraSettings { + mode:number; photo_modes:number[]; video_modes:number[]; photo_sizes:number[]; + video_resolutions:{id:number;name:string}[]; + values:Record; + attributes:Record; +} +export interface CameraFiles {items:string[];offset:number;total:number} +const state=(value:unknown)=>value===0||value===1||value===-1?value:null; +export function cameraStatus(device:Sensor):CameraStatus { + const value=device.camera_status??{}; + return {...value,connected:value.connected===true,preview:state(value.preview),recording:state(value.recording), + function_mode:typeof value.function_mode==='number'&&Number.isInteger(value.function_mode)?value.function_mode:null} as CameraStatus; +} +export function cameraLabel(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} { + if(!fresh||!device.online)return {label:fresh?'Камера отключена':'Нет свежих сведений',tone:'neutral'}; + if(device.initializable===false)return {label:'Не удалось определить камеру',tone:'warning'}; + if(!(device.configured??device.snapshot.enrollment==='enrolled'))return {label:'Требуется подготовка',tone:'neutral'}; + if(!device.prepared)return {label:'Драйвер недоступен',tone:'warning'}; + const value=cameraStatus(device); + if(!value.connected||value.recording===null||value.preview===null)return {label:'Состояние камеры не подтверждено',tone:'warning'}; + if(value.recording===1)return {label:'Запись на карту камеры',tone:'success'}; + if(value.recording===-1)return {label:'Состояние записи не подтверждено',tone:'warning'}; + if(value.preview===-1)return {label:'Состояние просмотра не подтверждено',tone:'warning'}; + if(value.preview===1)return {label:'Идёт просмотр',tone:'success'}; + return {label:'Подключена',tone:'success'}; +} +export function resolutionLabel(value:string):string { + const match=/^(\d+)_(\d+)_(\d+)(.*)$/.exec(value); + return match?`${match[1]} × ${match[2]} · ${match[3]} кадр/с${match[4]==='_plus'?' · повышенное качество':''}`:value; +} +export function fileName(value:string):string { + const path=value.split('?')[0].split('/').at(-1)??value; + try{return decodeURIComponent(path);}catch{return path;} +} diff --git a/plugins/insta360-x4/frontend/src/plugin.ts b/plugins/insta360-x4/frontend/src/plugin.ts new file mode 100644 index 0000000..3e7c649 --- /dev/null +++ b/plugins/insta360-x4/frontend/src/plugin.ts @@ -0,0 +1,8 @@ +import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions'; +import {X4Detail} from './X4Detail'; +import {cameraLabel} from './model'; + +export const insta360X4SensorUi:SensorUiContribution={ + kind:'insta360.x4',Detail:X4Detail,icon:'camera',retainOffline:true, + supportsPreparation:true,supportsRenaming:true,status:cameraLabel, +}; diff --git a/plugins/insta360-x4/native/bridge.cpp b/plugins/insta360-x4/native/bridge.cpp new file mode 100644 index 0000000..be79801 --- /dev/null +++ b/plugins/insta360-x4/native/bridge.cpp @@ -0,0 +1,429 @@ +#include "bridge.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace ins_camera; + +namespace { +constexpr size_t MAX_PACKET = 4 * 1024 * 1024; +constexpr size_t MAX_QUEUE = 8 * 1024 * 1024; +constexpr size_t MAX_RESULT = 60 * 1024; + +std::string quote(const std::string& value) { + if (value.size() > 1024) throw std::runtime_error("oversized SDK string"); + std::ostringstream out; + out << '"'; + for (unsigned char c : value) { + if (c == '"' || c == '\\') out << '\\' << c; + else if (c < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << int(c); + else out << c; + } + return out.str() + '"'; +} + +template bool contains(const std::vector& values, T value) { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +template std::string numbers(const std::vector& values) { + if (values.size() > 256) throw std::runtime_error("oversized capabilities"); + std::string out = "["; + for (auto v : values) { if (out.size() > 1) out += ','; out += std::to_string(int(v)); } + return out + ']'; +} + +std::string strings(const std::vector& values) { + if (values.size() > 256) throw std::runtime_error("oversized SDK list"); + std::string out = "["; + for (const auto& v : values) { if (out.size() > 1) out += ','; out += quote(v); } + return out + ']'; +} + +std::string complete(const std::string& result) { + return "{\"state\":\"complete\",\"result\":" + result + '}'; +} +const char* UNKNOWN = "{\"state\":\"unknown\",\"error\":\"camera_result_unconfirmed\"}"; +const char* INVALID = "{\"state\":\"error\",\"error\":\"unsupported_camera_parameter\"}"; + +struct Packet { mc_x4_video info; std::vector bytes; }; +struct Frames final : StreamDelegate { + std::mutex mutex; + std::deque queue; + size_t bytes = 0; + uint64_t generation = 0, sequence = 0; + std::atomic codec{0}; + std::atomic enabled{false}; + + void reset() { + std::lock_guard lock(mutex); + queue.clear(); bytes = 0; ++generation; + } + void OnVideoData(const uint8_t* data, size_t size, int64_t timestamp, + uint8_t, int stream_index) override { + if (!enabled || !data || !size || size > MAX_PACKET || stream_index < 0 || stream_index > 1) return; + // SDK owns the callback bytes. Copy into a bounded queue; never block + // a vendor callback on a browser, disk, decoder or Python interpreter. + try { + std::lock_guard lock(mutex); + if (bytes + size > MAX_QUEUE || queue.size() >= 64) { + queue.clear(); bytes = 0; ++generation; + } + queue.push_back({{++sequence, generation, timestamp, stream_index, + codec.load(), uint32_t(size)}, {data, data + size}}); + bytes += size; + } catch (...) { + // A memory allocation failure drops data; it must not unwind into + // the vendor's callback thread or terminate camera recording. + } + } + void OnAudioData(const uint8_t*, size_t, int64_t) override {} + void OnGyroData(const std::vector&) override {} + void OnExposureData(const ExposureData&) override {} +}; + +struct Notifications { + std::mutex mutex; + std::atomic recording{-1}; + std::atomic revision{0}; + std::atomic stopped_reason{-1}; + std::atomic battery_low{false}, storage_full{false}, temperature_high{false}; +}; +} + +struct mc_x4 { + DeviceDiscovery discovery; + std::vector descriptors; + std::shared_ptr camera; + std::shared_ptr frames = std::make_shared(); + std::shared_ptr notifications = std::make_shared(); + std::string firmware, result; + bool opened = false; + int preview = 0; + // Only the isolated worker calls control methods; callbacks use separate + // shared state that remains alive until the vendor releases its callbacks. + ~mc_x4() { + frames->enabled = false; + if (camera && opened) { try { camera->Close(); } catch (...) {} } + camera.reset(); + try { discovery.FreeDeviceDescriptors(descriptors); } catch (...) {} + } + + bool mode_supported(CameraFunctionMode mode) { + return contains(camera->GetSupportedVideoModes(), mode) || contains(camera->GetSupportedPhotoModes(), mode); + } + + std::string status() { + BatteryStatus battery{}; + StorageStatus storage{}; + bool battery_ok = camera->GetBatteryStatus(battery); + bool storage_ok = camera->GetStorageState(storage); + // The SDK exposes a boolean capture poll, not a separate timeout code. + // Report its value only while independent status reads succeed; never + // replace a notification that arrived during the blocking poll. + auto revision = notifications->revision.load(); + bool active = camera->CaptureCurrentStatus(); + bool connected = camera->IsConnected(); + { + std::lock_guard lock(notifications->mutex); + if (revision == notifications->revision.load()) { + notifications->recording = connected && battery_ok && storage_ok ? (active ? 1 : 0) : -1; + } + } + std::string result = "{\"firmware\":" + quote(firmware) + + ",\"connected\":" + (connected ? "true" : "false") + + ",\"preview\":" + std::to_string(preview) + + ",\"recording\":" + std::to_string(notifications->recording) + + ",\"stopped_reason\":" + std::to_string(notifications->stopped_reason) + + ",\"function_mode\":" + std::to_string(int(camera->GetCurrentFunctionMode())) + + ",\"codec\":" + std::to_string(int(camera->GetVideoEncodeType())) + + ",\"battery\":"; + result += battery_ok ? "{\"level\":" + std::to_string(battery.battery_level) + + ",\"scale\":" + std::to_string(battery.battery_scale) + + ",\"power_type\":" + std::to_string(int(battery.power_type)) + "}" : "null"; + result += ",\"storage\":"; + result += storage_ok ? "{\"state\":" + std::to_string(int(storage.state)) + + ",\"free_bytes\":" + std::to_string(storage.free_space) + + ",\"total_bytes\":" + std::to_string(storage.total_space) + "}" : "null"; + result += ",\"alerts\":{\"battery_low\":" + std::string(notifications->battery_low ? "true" : "false") + + ",\"storage_full\":" + (notifications->storage_full ? "true" : "false") + + ",\"temperature_high\":" + (notifications->temperature_high ? "true" : "false") + "}}"; + return complete(result); + } + + std::string settings(CameraFunctionMode mode) { + if (!mode_supported(mode)) return INVALID; + if (!camera->SyncPhotographyOptions(mode)) return UNKNOWN; + auto capture = camera->GetCaptureSettings(mode); + auto exposure = camera->GetExposureSettings(mode); + if (!capture || !exposure || !std::isfinite(exposure->ShutterSpeed())) return UNKNOWN; + std::ostringstream out; + out.imbue(std::locale::classic()); + out << "{\"mode\":" << int(mode) + << ",\"photo_modes\":" << numbers(camera->GetSupportedPhotoModes()) + << ",\"video_modes\":" << numbers(camera->GetSupportedVideoModes()) + << ",\"photo_sizes\":" << numbers(camera->GetSupportedPhotoSizes(mode)) + << ",\"video_resolutions\":["; + auto resolutions = camera->GetSupportedVideoResolutions(mode); + if (resolutions.size() > 256) return UNKNOWN; + bool first = true; + for (auto resolution : resolutions) { + if (!first) out << ','; + first = false; + out << "{\"id\":" << int(resolution) << ",\"name\":" << quote(camera->GetVideoResolutionName(resolution)) << '}'; + } + out << "],\"values\":{\"iso\":" << exposure->Iso() + << ",\"shutter_seconds\":" << std::setprecision(17) << exposure->ShutterSpeed() + << ",\"exposure_mode\":" << int(exposure->ExposureMode()) + << ",\"ev_twentieths\":" << exposure->EVBias() + << ",\"white_balance\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_WhiteBalance) + << ",\"video_resolution\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_RecordResolution) + << ",\"photo_size\":" << capture->GetIntValue(CaptureSettings::CaptureSettings_PhotoResolution) + << "},\"attributes\":{"; + auto names = camera->GetSupportedAttrNames(mode); + if (names.size() > 128) return UNKNOWN; + first = true; + for (const auto& name : names) { + if (!first) out << ','; + first = false; + out << quote(name) << ":{\"values\":" << strings(camera->GetSupportedAttrValues(mode, name)) + << ",\"depends_on\":" << strings(camera->GetAttrDependOn(mode, name)) << '}'; + } + return complete(out.str() + "}}"); + } + + std::string apply(CameraFunctionMode mode, const std::string& key, double value) { + if (!mode_supported(mode) || !std::isfinite(value)) return INVALID; + status(); + if (notifications->recording != 0 || camera->CaptureCurrentStatus()) return INVALID; + // One parameter per operation: partial application of a multi-setting + // request must never be presented as an atomic success. + if (!camera->SyncPhotographyOptions(mode)) return UNKNOWN; + bool acknowledged = false; + if (key == "function_mode") { + if (value == FUNCTION_MODE_NORMAL_VIDEO && contains(camera->GetSupportedVideoModes(), FUNCTION_MODE_NORMAL_VIDEO)) + acknowledged = camera->SetVideoSubMode(VIDEO_NORMAL); + else if (value == FUNCTION_MODE_NORMAL_IMAGE && contains(camera->GetSupportedPhotoModes(), FUNCTION_MODE_NORMAL_IMAGE)) + acknowledged = camera->SetPhotoSubMode(PHOTO_SINGLE); + else return INVALID; + if (!acknowledged || int(camera->GetCurrentFunctionMode()) != int(value)) return UNKNOWN; + return settings(static_cast(int(value))); + } else if (key == "video_resolution" && value == std::floor(value) && value >= 0 && value <= 4096) { + auto resolution = static_cast(int(value)); + if (!contains(camera->GetSupportedVideoResolutions(mode), resolution)) return INVALID; + RecordParams params{}; params.resolution = resolution; + acknowledged = camera->SetVideoCaptureParams(params, mode); + } else if (key == "photo_size" && value == std::floor(value) && value >= 0 && value <= 4096) { + auto size = static_cast(int(value)); + if (!contains(camera->GetSupportedPhotoSizes(mode), size)) return INVALID; + acknowledged = camera->SetPhotoSize(mode, size); + } else { + // Exposure/white-balance ranges must come from this camera's + // capability table. No undocumented enum or guessed write. + const std::string attr = key == "iso" ? "exposure_iso" : key == "white_balance" ? "white_balance" : key == "exposure_mode" ? "exposure_program" : ""; + if (attr.empty() || value != std::floor(value) || value < 0 || value > 65535) return INVALID; + std::string context; + for (const auto& dependency : camera->GetAttrDependOn(mode, attr)) { + // Resolve the current camera state, never a client-provided + // context that could widen a dependent capability branch. + auto current = camera->GetCaptureSettings(mode); + if (!current || dependency != "record_resolution") return INVALID; + if (!context.empty()) context += '|'; + context += camera->GetVideoResolutionName(static_cast(current->GetIntValue(CaptureSettings::CaptureSettings_RecordResolution))); + if (context.empty()) return INVALID; + } + auto values = camera->GetSupportedAttrValues(mode, attr, context); + bool admitted = false; + for (const auto& option : values) { + if (option == std::to_string(int(value)) || camera->GetAttrValueByName(attr, option) == int(value)) admitted = true; + } + if (!admitted) return INVALID; + if (key == "iso") { + auto settings = camera->GetExposureSettings(mode); + if (!settings || (settings->ExposureMode() != MANUAL && settings->ExposureMode() != ISO_PRIORITY)) return INVALID; + settings->SetIso(int(value)); + acknowledged = camera->SetExposureSettings(mode, settings); + } else if (key == "exposure_mode") { + if (value > 5) return INVALID; + auto settings = camera->GetExposureSettings(mode); + if (!settings) return UNKNOWN; + settings->SetExposureMode(static_cast(int(value))); + acknowledged = camera->SetExposureSettings(mode, settings); + } else { + auto settings = camera->GetCaptureSettings(mode); + if (!settings) return UNKNOWN; + settings->ResetSettingTypes(); + // X4 capabilities and the pinned SDK example use Kelvin (0 + // auto) via SetValue, not the legacy 0..5 WB enum. + settings->SetValue(CaptureSettings::CaptureSettings_WhiteBalance, int(value)); + acknowledged = camera->SetCaptureSettings(mode, settings); + } + } + // Returning the actual refreshed values lets the caller distinguish an + // acknowledged request from a setting the camera declined to retain. + return acknowledged ? settings(mode) : UNKNOWN; + } + + std::string call(int action, int mode_number, const std::string& key, double value) { + if (mode_number < 0 || mode_number > 255 || !std::isfinite(value)) return INVALID; + if (!camera->IsConnected()) return UNKNOWN; + auto mode = static_cast(mode_number); + if (action == MC_X4_STATUS) return status(); + if (action == MC_X4_SETTINGS_READ) return settings(mode); + if (action == MC_X4_SETTINGS_APPLY) return apply(mode, key, value); + if (action == MC_X4_PREVIEW_START) { + if (preview == 1) return complete("{\"ok\":true}"); + if (preview == -1) return UNKNOWN; + LiveStreamParam params{}; + params.enable_audio = false; params.enable_gyro = false; params.using_lrv = false; + params.video_resolution = RES_1920_960P30; params.lrv_video_resulution = RES_1920_960P30; + params.video_bitrate = 4000000; + frames->reset(); frames->codec = int(camera->GetVideoEncodeType()); frames->enabled = true; + preview = -1; + bool ok = camera->StartLiveStreaming(params); + frames->codec = int(camera->GetVideoEncodeType()); + if (!ok) { frames->enabled = false; return UNKNOWN; } + preview = 1; + return complete("{\"ok\":true}"); + } + if (action == MC_X4_PREVIEW_STOP) { + if (preview == 0) return complete("{\"ok\":true}"); + preview = -1; + if (!camera->StopLiveStreaming()) return UNKNOWN; + preview = 0; frames->enabled = false; frames->reset(); + return complete("{\"ok\":true}"); + } + if (action == MC_X4_RECORD_START) { + if (camera->CaptureCurrentStatus()) { + notifications->recording = 1; + return complete("{\"recording\":true}"); + } + if (camera->GetCurrentFunctionMode() != FUNCTION_MODE_NORMAL_VIDEO) return INVALID; + uint64_t revision; + { + std::lock_guard lock(notifications->mutex); + revision = notifications->revision; notifications->recording = -1; + } + if (!camera->StartRecording()) return UNKNOWN; + { + std::lock_guard lock(notifications->mutex); + // A storage/temperature stop notification can precede the + // START acknowledgement. Never overwrite that newer fact. + if (notifications->revision == revision) notifications->recording = 1; + if (notifications->recording != 1) return UNKNOWN; + } + return complete("{\"recording\":true}"); + } + if (action == MC_X4_RECORD_STOP) { + uint64_t revision; + { + std::lock_guard lock(notifications->mutex); + revision = notifications->revision; notifications->recording = -1; + } + auto media = camera->StopRecording(); + if (media.Empty()) return UNKNOWN; + { + std::lock_guard lock(notifications->mutex); + if (notifications->revision == revision) notifications->recording = 0; + if (notifications->recording != 0) return UNKNOWN; + } + return complete("{\"recording\":false,\"files\":" + strings(media.OriginUrls()) + '}'); + } + if (action == MC_X4_PHOTO) { + status(); + if (notifications->recording != 0 || camera->CaptureCurrentStatus()) return INVALID; + // Capture the operator-selected mode. Initialization never changes + // it or takes a photo as a side effect of verifying live frames. + if (camera->GetCurrentFunctionMode() != FUNCTION_MODE_NORMAL_IMAGE) return INVALID; + auto media = camera->TakePhoto(RawCaptureType::PureShot, 15000); + return media.Empty() ? UNKNOWN : complete("{\"files\":" + strings(media.OriginUrls()) + '}'); + } + if (action == MC_X4_FILES) { + if (value < 0 || value > 100000 || value != std::floor(value)) return INVALID; + auto files = camera->GetCameraFilesList(); + size_t offset = size_t(value), stop = std::min(files.size(), offset + 32); + if (files.size() > 100000 || offset > files.size()) return INVALID; + std::vector page(files.begin() + offset, files.begin() + stop); + return complete("{\"items\":" + strings(page) + ",\"total\":" + std::to_string(files.size()) + + ",\"offset\":" + std::to_string(offset) + '}'); + } + return INVALID; + } +}; + +extern "C" { +int mc_x4_abi(void) { return 1; } +mc_x4* mc_x4_open(const char* serial, const char* log_directory) { + try { + if (!serial || !*serial || std::strlen(serial) > 256 || !log_directory || !*log_directory) return nullptr; + auto handle = std::make_unique(); + SetLogPath(log_directory); + SetLogLevel(LogLevel::ERR); + handle->descriptors = handle->discovery.GetAvailableDevices(); + // The service namespace must expose exactly one physical camera. + // Never take list[0] from a host-wide SDK enumeration. + if (handle->descriptors.size() != 1) return nullptr; + const auto& device = handle->descriptors.front(); + if (device.camera_type != CameraType::Insta360X4 || device.info.connection_type != ConnectionType::USB || device.serial_number != serial) return nullptr; + handle->firmware = device.fw_version; + handle->camera = std::make_shared(device.info); + handle->camera->SetServicePort(9099); // private network namespace per instance + handle->camera->SetTimeout(10000); + handle->opened = handle->camera->Open(); + if (!handle->opened) return nullptr; + auto n = handle->notifications; + handle->camera->SetCaptureStateNotification([n](bool active) { + std::lock_guard lock(n->mutex); + n->recording = active ? 1 : 0; ++n->revision; + }); + handle->camera->SetCaptureStoppedNotification([n](const std::string&, int reason) { + std::lock_guard lock(n->mutex); + n->recording = 0; n->stopped_reason = reason; ++n->revision; + }); + handle->camera->SetBatteryLowNotification([n](int) { n->battery_low = true; }); + handle->camera->SetStorageFullNotification([n]() { n->storage_full = true; }); + handle->camera->SetTemperatureHighNotification([n]() { n->temperature_high = true; }); + std::shared_ptr delegate = handle->frames; + handle->camera->SetStreamDelegate(delegate); + return handle.release(); + } catch (...) { return nullptr; } +} +const char* mc_x4_call(mc_x4* handle, int action, int mode, const char* key, double value) { + if (!handle) return UNKNOWN; + try { + if (key && std::strlen(key) > 64) return INVALID; + handle->result = handle->call(action, mode, key ? key : "", value); + if (handle->result.size() > MAX_RESULT) handle->result = UNKNOWN; + return handle->result.c_str(); + } catch (...) { return UNKNOWN; } +} +int mc_x4_read_video(mc_x4* handle, mc_x4_video* info, uint8_t* buffer, size_t capacity) { + if (!handle || !info || !buffer) return -1; + try { + std::lock_guard lock(handle->frames->mutex); + if (handle->frames->queue.empty()) return 0; + auto& packet = handle->frames->queue.front(); + if (capacity < packet.bytes.size()) return -1; + *info = packet.info; + std::memcpy(buffer, packet.bytes.data(), packet.bytes.size()); + handle->frames->bytes -= packet.bytes.size(); + handle->frames->queue.pop_front(); + return 1; + } catch (...) { return -1; } +} +void mc_x4_close(mc_x4* handle) { try { delete handle; } catch (...) {} } +} diff --git a/plugins/insta360-x4/native/bridge.h b/plugins/insta360-x4/native/bridge.h new file mode 100644 index 0000000..ba8ad50 --- /dev/null +++ b/plugins/insta360-x4/native/bridge.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include + +#if defined(__GNUC__) +#define MC_X4_API __attribute__((visibility("default"))) +#else +#define MC_X4_API +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// Private ABI v1. One handle in each physically isolated camera worker. The +// caller must restrict USB access BEFORE loading this library or discovering. +typedef struct mc_x4 mc_x4; +enum mc_x4_action { + MC_X4_STATUS = 1, MC_X4_SETTINGS_READ = 2, MC_X4_SETTINGS_APPLY = 3, + MC_X4_PREVIEW_START = 4, MC_X4_PREVIEW_STOP = 5, + MC_X4_RECORD_START = 6, MC_X4_RECORD_STOP = 7, MC_X4_PHOTO = 8, + MC_X4_FILES = 9 +}; +typedef struct mc_x4_video { + uint64_t sequence; + uint64_t generation; // changes on queue overflow / preview restart + int64_t timestamp; // camera clock; units are not assumed by the bridge + int32_t stream_index; + int32_t codec; // 0 = H264, 1 = H265 + uint32_t bytes; +} mc_x4_video; + +MC_X4_API int mc_x4_abi(void); +MC_X4_API mc_x4* mc_x4_open(const char* expected_serial, const char* private_log_directory); +// JSON result owned by the handle; copy before the next call. Calls are +// serialized by the worker. No vendor exception or C++ object crosses the ABI. +MC_X4_API const char* mc_x4_call(mc_x4*, int action, int mode, const char* key, double value); +MC_X4_API int mc_x4_read_video(mc_x4*, mc_x4_video*, uint8_t* buffer, size_t capacity); +// Does not call StopRecording or change auto-stop-on-disconnect policy. +MC_X4_API void mc_x4_close(mc_x4*); + +#ifdef __cplusplus +} +#endif diff --git a/plugins/insta360-x4/native/tests/check_abi.py b/plugins/insta360-x4/native/tests/check_abi.py new file mode 100644 index 0000000..cec4eba --- /dev/null +++ b/plugins/insta360-x4/native/tests/check_abi.py @@ -0,0 +1,211 @@ +"""Execute OUR native adapter against synthetic SDK symbols, never vendor code. + +Requires the separately acquired, locked SDK headers and a local C++ compiler. +This does not substitute for Linux linking, USB isolation or hardware tests. +""" + +import ctypes +import json +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +class Header(ctypes.Structure): + _fields_ = [ + ("sequence", ctypes.c_uint64), + ("generation", ctypes.c_uint64), + ("timestamp", ctypes.c_int64), + ("stream_index", ctypes.c_int32), + ("codec", ctypes.c_int32), + ("bytes", ctypes.c_uint32), + ] + + +def library(folder): + compiler = shutil.which("clang++") or shutil.which("g++") + if not compiler or not (ROOT / "build/sdk/include/camera/camera.h").exists(): + raise unittest.SkipTest( + "Locked SDK headers and a C++ compiler are required for the native simulator" + ) + target = folder / ("adapter.dylib" if sys.platform == "darwin" else "adapter.so") + subprocess.run( + [ + compiler, + "-std=c++17", + "-Wall", + "-Wextra", + "-Werror", + "-shared", + "-fPIC", + "-pthread", + "-I", + str(ROOT / "build/sdk/include"), + str(ROOT / "native/bridge.cpp"), + str(ROOT / "native/tests/fake_sdk.cpp"), + "-o", + str(target), + ], + check=True, + timeout=60, + capture_output=True, + ) + api = ctypes.CDLL(str(target)) + api.mc_x4_open.argtypes = [ctypes.c_char_p, ctypes.c_char_p] + api.mc_x4_open.restype = ctypes.c_void_p + api.mc_x4_call.argtypes = [ + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_double, + ] + api.mc_x4_call.restype = ctypes.c_char_p + api.mc_x4_read_video.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(Header), + ctypes.POINTER(ctypes.c_uint8), + ctypes.c_size_t, + ] + api.mc_x4_read_video.restype = ctypes.c_int + api.mc_x4_close.argtypes = [ctypes.c_void_p] + api.mc_x4_close.restype = None + return api + + +def call(camera, action, mode=0, key="", value=0): + api, handle = camera + return json.loads(api.mc_x4_call(handle, action, mode, key.encode(), value)) + + +def test_discovery_never_selects_first_of_multiple_cameras(library): + library.test_reset() + library.test_discovered(2) + assert not library.mc_x4_open(b"SYNTHETIC-X4", b"/unused") + assert library.test_count(0) == 0 and library.test_count(2) == 1 + library.test_discovered(1) + assert not library.mc_x4_open(b"WRONG-SERIAL", b"/unused") + assert library.test_count(0) == 0 and library.test_count(2) == 2 + + +def test_preview_stop_and_close_do_not_stop_recording(camera): + api, handle = camera + assert call(camera, 6)["state"] == "complete" + assert call(camera, 4)["state"] == "complete" + assert call(camera, 5)["state"] == "complete" + assert call(camera, 1)["result"]["recording"] == 1 + assert api.test_count(5) == 0 and api.test_count(7) == 1 + # Fixture closes the SDK handle after assertions; test the cleanup counter + # independently by closing a second handle in the next dedicated test. + + +def test_close_only_releases_sdk_session(library): + library.test_reset() + handle = library.mc_x4_open(b"SYNTHETIC-X4", b"/unused") + library.test_recording(1) + library.mc_x4_close(handle) + assert library.test_count(1) == 1 and library.test_count(2) == 1 + assert library.test_count(5) == 0 + + +def test_lost_start_ack_is_unknown_and_stop_remains_possible(camera): + api, _ = camera + api.test_ack(0, 0) + assert call(camera, 6)["state"] == "unknown" + assert call(camera, 1)["result"]["recording"] == 1 + assert call(camera, 7)["state"] == "complete" + assert api.test_count(4) == 1 and api.test_count(5) == 1 + api.test_ack(1, 0) + assert call(camera, 4)["state"] == "unknown" + assert call(camera, 4)["state"] == "unknown" + assert api.test_count(6) == 1 + assert call(camera, 5)["state"] == "complete" + assert api.test_count(7) == 1 + + +def test_capabilities_reject_unsupported_values_and_recording_mutations(camera): + api, _ = camera + assert call(camera, 3, 7, "video_resolution", 999)["state"] == "error" + assert ( + call(camera, 3, 7, "iso", 6400)["state"] == "error" + ) # current resolution branch excludes it + assert call(camera, 3, 7, "iso", 400)["state"] == "complete" + result = call(camera, 3, 7, "white_balance", 5000) + assert result["result"]["values"]["white_balance"] == 5000 # Kelvin, not legacy enum + assert api.test_count(3) == 2 + api.test_recording(1) + assert call(camera, 3, 7, "video_resolution", 2)["state"] == "error" + assert api.test_count(3) == 2 + api.test_connected(0) + assert call(camera, 3, 7, "white_balance", 0)["state"] == "unknown" + assert api.test_count(3) == 2 + + +def test_frames_copy_vendor_memory_preserve_stream_and_bound_backlog(camera): + api, handle = camera + call(camera, 4) + api.test_packet(1, 24, 91) + info, buffer = Header(), (ctypes.c_uint8 * 24)() + assert api.mc_x4_read_video(handle, ctypes.byref(info), buffer, 24) == 1 + assert (info.stream_index, info.timestamp, info.bytes) == (1, 12345, 24) + assert bytes(buffer) == bytes([91]) * 24 + generation = info.generation + for i in range(65): + api.test_packet(0, 24, i) + assert api.mc_x4_read_video(handle, ctypes.byref(info), buffer, 24) == 1 + assert info.generation > generation # decoder must reset after this loss + assert bytes(buffer) == bytes([64]) * 24 + assert api.mc_x4_read_video(handle, ctypes.byref(info), buffer, 24) == 0 + + +def test_camera_files_are_bounded_pages(camera): + first = call(camera, 9)["result"] + last = call(camera, 9, value=64)["result"] + assert len(first["items"]) == 32 and first["total"] == 65 + assert len(last["items"]) == 1 and last["offset"] == 64 + assert call(camera, 9, value=66)["state"] == "error" + + +def test_early_camera_stop_notification_wins_over_start_acknowledgement(camera): + api, _ = camera + api.test_ack(2, 1) + assert call(camera, 6)["state"] == "unknown" + assert call(camera, 1)["result"]["recording"] == 0 + assert api.test_count(4) == 1 + + +class TestNativeBridge(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.folder = tempfile.TemporaryDirectory(prefix="x4-native-synthetic-", dir=ROOT / "build") + cls.addClassCleanup(cls.folder.cleanup) + cls.api = library(Path(cls.folder.name)) + + +def test_case(function): + def execute(self): + self.api.test_reset() + if function.__code__.co_varnames[0] == "library": + function(self.api) + return + handle = self.api.mc_x4_open(b"SYNTHETIC-X4", b"/unused") + self.assertTrue(handle) + try: + function((self.api, handle)) + finally: + self.api.mc_x4_close(handle) + + return execute + + +for test_name, test_function in list(globals().items()): + if test_name.startswith("test_") and test_name != "test_case": + setattr(TestNativeBridge, test_name, test_case(test_function)) + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/insta360-x4/native/tests/fake_sdk.cpp b/plugins/insta360-x4/native/tests/fake_sdk.cpp new file mode 100644 index 0000000..c73218c --- /dev/null +++ b/plugins/insta360-x4/native/tests/fake_sdk.cpp @@ -0,0 +1,129 @@ +// Test double for the pinned PUBLIC HEADERS. No vendor .so is linked/loaded. +// Deliberately models ambiguous acknowledgements and separate preview/recording. +#include +#include +#include +#include + +namespace { +int discovered = 1, opens = 0, closes = 0, freed = 0, writes = 0; +int record_starts = 0, record_stops = 0, previews = 0, preview_stops = 0; +bool recording = false, start_ack = true, preview_ack = true, connected = true; +bool stop_before_ack = false; +int function_mode = 7; +std::shared_ptr delegate; +ins_camera::CaptureStateCallBack capture_callback; +std::shared_ptr capture; +std::shared_ptr exposure; +} + +namespace ins_camera { +class CameraImpl {}; +class ExposureSettingsPrivate { +public: + int iso = 100, ev = 0; + double shutter = 1.0 / 60; + PhotographyOptions_ExposureMode mode = MANUAL; +}; +void SetLogPath(const std::string&) {} +void SetLogLevel(LogLevel) {} +Camera::Camera(const DeviceConnectionInfo&) {} +bool Camera::Open() const { ++opens; return true; } +void Camera::Close() const { ++closes; delegate.reset(); capture_callback = nullptr; } +void Camera::SetServicePort(int) {} +void Camera::SetTimeout(int) {} +bool Camera::IsConnected() { return connected; } +bool Camera::CaptureCurrentStatus() const { return recording; } +bool Camera::GetBatteryStatus(BatteryStatus& out) { out = {ADAPTER, 80, 100}; return connected; } +bool Camera::GetStorageState(StorageStatus& out, CardLocation) { out = {STOR_CS_PASS, 1000, 2000}; return connected; } +CameraFunctionMode Camera::GetCurrentFunctionMode() const { return static_cast(function_mode); } +VideoEncodeType Camera::GetVideoEncodeType() const { return VideoEncodeType::H264; } +void Camera::SetStreamDelegate(std::shared_ptr& value) { delegate = value; } +void Camera::SetCaptureStateNotification(CaptureStateCallBack cb) { capture_callback = cb; } +void Camera::SetCaptureStoppedNotification(CaptureStoppedCallBack) {} +void Camera::SetBatteryLowNotification(BatteryLowCallBack) {} +void Camera::SetStorageFullNotification(StorageFullCallBack) {} +void Camera::SetTemperatureHighNotification(TemperatureHighCallBack) {} +bool Camera::StartRecording() { + ++record_starts; recording = true; + if (stop_before_ack) { recording = false; if (capture_callback) capture_callback(false); } + return start_ack; +} +MediaUrl Camera::StopRecording() { ++record_stops; recording = false; return MediaUrl({"/DCIM/synthetic.insv"}); } +bool Camera::StartLiveStreaming(const LiveStreamParam& param) { + if (param.enable_audio || param.enable_gyro || param.using_lrv || param.video_resolution != RES_1920_960P30) throw std::runtime_error("unexpected preview profile"); + ++previews; return preview_ack; +} +bool Camera::StopLiveStreaming() { ++preview_stops; return true; } +MediaUrl Camera::TakePhoto(RawCaptureType, int) const { return MediaUrl({"/DCIM/synthetic.insp"}); } +std::vector Camera::GetCameraFilesList() const { + std::vector out; + for (int i = 0; i < 65; ++i) out.push_back("/DCIM/synthetic_" + std::to_string(i) + ".insv"); + return out; +} +std::vector DeviceDiscovery::GetAvailableDevices() { + std::vector out; + for (int i = 0; i < discovered; ++i) out.push_back({CameraType::Insta360X4, "SYNTHETIC-X4", "Insta360 X4", "TEST-FW", {ConnectionType::USB, "Insta360 X4", nullptr}}); + return out; +} +void DeviceDiscovery::FreeDeviceDescriptors(std::vector) { ++freed; } +MediaUrl::MediaUrl(const std::vector& origins, const std::vector& proxies): uris_(origins), lrv_uris_(proxies) {} +bool MediaUrl::Empty() const { return uris_.empty(); } +const std::vector& MediaUrl::OriginUrls() const { return uris_; } + +bool Camera::SyncPhotographyOptions(CameraFunctionMode) { return connected; } +std::shared_ptr Camera::GetCaptureSettings(CameraFunctionMode) const { return capture; } +std::shared_ptr Camera::GetExposureSettings(CameraFunctionMode) const { return exposure; } +bool Camera::SetCaptureSettings(CameraFunctionMode, std::shared_ptr value) { ++writes; capture = value; return true; } +bool Camera::SetExposureSettings(CameraFunctionMode, const std::shared_ptr& value) { ++writes; exposure = value; return true; } +bool Camera::SetVideoCaptureParams(RecordParams value, CameraFunctionMode) { ++writes; capture->SetValue(CaptureSettings::CaptureSettings_RecordResolution, int(value.resolution)); return true; } +bool Camera::SetPhotoSize(CameraFunctionMode, const PhotoSize& size) { ++writes; capture->SetValue(CaptureSettings::CaptureSettings_PhotoResolution, int(size)); return true; } +bool Camera::SetVideoSubMode(SubVideoMode) { ++writes; function_mode = 7; return true; } +bool Camera::SetPhotoSubMode(SubPhotoMode) { ++writes; function_mode = 6; return true; } +std::vector Camera::GetSupportedVideoModes() const { return {FUNCTION_MODE_NORMAL_VIDEO}; } +std::vector Camera::GetSupportedPhotoModes() const { return {FUNCTION_MODE_NORMAL_IMAGE}; } +std::vector Camera::GetSupportedPhotoSizes(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_IMAGE ? std::vector{Size_5952_2976} : std::vector{}; } +std::vector Camera::GetSupportedVideoResolutions(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_VIDEO ? std::vector{RES_3840_1920P30, RES_1920_960P30} : std::vector{}; } +std::string Camera::GetVideoResolutionName(VideoResolution value) const { return value == RES_1920_960P30 ? "1920_960_30" : "3840_1920_30"; } +std::vector Camera::GetSupportedAttrNames(CameraFunctionMode) const { return {"white_balance", "exposure_iso", "exposure_program"}; } +std::vector Camera::GetAttrDependOn(CameraFunctionMode, const std::string& name) const { return name == "exposure_iso" ? std::vector{"record_resolution"} : std::vector{}; } +std::vector Camera::GetSupportedAttrValues(CameraFunctionMode, const std::string& name, const std::string& context) const { + if (name == "white_balance") return {"0", "5000"}; + if (name == "exposure_program") return {"AUTO", "MANUAL"}; + if (name == "exposure_iso") return context.empty() ? std::vector{"100", "6400"} : std::vector{"100", "400"}; + return {}; +} +int Camera::GetAttrValueByName(const std::string&, const std::string& value) { return value == "AUTO" ? 0 : value == "MANUAL" ? 3 : -1; } +void CaptureSettings::ResetSettingTypes() { types_.clear(); } +void CaptureSettings::SetValue(SettingsType type, int32_t value, bool) { int_values_[type] = value; } +int32_t CaptureSettings::GetIntValue(SettingsType type) const { auto v = int_values_.find(type); return v == int_values_.end() ? 0 : v->second; } +ExposureSettings::ExposureSettings(): private_impl_(std::make_shared()) {} +int32_t ExposureSettings::Iso() const { return private_impl_->iso; } +double ExposureSettings::ShutterSpeed() const { return private_impl_->shutter; } +PhotographyOptions_ExposureMode ExposureSettings::ExposureMode() const { return private_impl_->mode; } +int32_t ExposureSettings::EVBias() const { return private_impl_->ev; } +void ExposureSettings::SetIso(int32_t value) { private_impl_->iso = value; } +void ExposureSettings::SetExposureMode(PhotographyOptions_ExposureMode value) { private_impl_->mode = value; } +} + +extern "C" { +void test_reset() { + discovered = 1; opens = closes = freed = writes = record_starts = record_stops = previews = preview_stops = 0; + recording = stop_before_ack = false; connected = start_ack = preview_ack = true; function_mode = 7; + delegate.reset(); capture_callback = nullptr; + capture = std::make_shared(); exposure = std::make_shared(); +} +int test_count(int which) { + switch (which) { case 0: return opens; case 1: return closes; case 2: return freed; case 3: return writes; case 4: return record_starts; case 5: return record_stops; case 6: return previews; case 7: return preview_stops; default: return -1; } +} +void test_discovered(int count) { discovered = count; } +void test_ack(int type, int value) { if (type == 0) start_ack = value; else if (type == 1) preview_ack = value; else stop_before_ack = value; } +void test_connected(int value) { connected = value; } +void test_recording(int value) { recording = value; if (capture_callback) capture_callback(recording); } +void test_packet(int stream_index, int size, int marker) { + if (!delegate) return; + std::vector packet(size, uint8_t(marker)); + delegate->OnVideoData(packet.data(), packet.size(), 12345, 0, stream_index); + std::fill(packet.begin(), packet.end(), 0); // prove bridge owns copied bytes +} +} diff --git a/plugins/insta360-x4/packaging/50-mission-core-insta360.rules b/plugins/insta360-x4/packaging/50-mission-core-insta360.rules new file mode 100644 index 0000000..b081506 --- /dev/null +++ b/plugins/insta360-x4/packaging/50-mission-core-insta360.rules @@ -0,0 +1,5 @@ +polkit.addRule(function(action, subject) { + if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && action.lookup("unit") === "mission-core-node-insta360-x4-prepare.service" && action.lookup("verb") === "start") { + return polkit.Result.YES; + } +}); diff --git a/plugins/insta360-x4/packaging/70-mission-core-insta360.rules b/plugins/insta360-x4/packaging/70-mission-core-insta360.rules new file mode 100644 index 0000000..4e1bdca --- /dev/null +++ b/plugins/insta360-x4/packaging/70-mission-core-insta360.rules @@ -0,0 +1 @@ +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTR{idVendor}=="2e1a", ATTR{idProduct}=="0002", ATTR{product}=="Insta360 X4", GROUP="mission-core-x4-usb", MODE="0660" diff --git a/plugins/insta360-x4/packaging/bootstrap.py b/plugins/insta360-x4/packaging/bootstrap.py new file mode 100644 index 0000000..04f1c8c --- /dev/null +++ b/plugins/insta360-x4/packaging/bootstrap.py @@ -0,0 +1,32 @@ +"""Isolated, root-owned bootstrap; no dependency on system Python packages.""" + +import os +import sys +from pathlib import Path + +CODE = Path(__file__).resolve().parent +sys.path.insert(0, str(CODE)) +from layout import active, trusted # noqa: E402 + + +def main(): + trusted(CODE, True) + runtime = trusted(active(), True) + trusted(runtime / "python", True) + sys.path[:0] = [str(runtime / "python"), str(CODE)] + os.umask(0o007) + if sys.argv[1:] == ["broker"]: + from runtime.broker import main as run + + run() + elif len(sys.argv) == 3 and sys.argv[1] == "worker": + from runtime.worker import main as run + + os.chdir(runtime / "bin") + run(sys.argv[2], runtime) + else: + raise ValueError("Unsupported runtime entry point") + + +if __name__ == "__main__": + main() diff --git a/plugins/insta360-x4/packaging/build_deb.py b/plugins/insta360-x4/packaging/build_deb.py new file mode 100644 index 0000000..07d82b2 --- /dev/null +++ b/plugins/insta360-x4/packaging/build_deb.py @@ -0,0 +1,230 @@ +"""Build the optional X4 package from pinned SDK, native output and wheel bytes.""" + +import argparse +import hashlib +import io +import json +import os +import sys +import zipfile +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[1] +REPOSITORY = ROOT.parents[1] +PACKAGING = ROOT / "packaging" +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-3" +WHEELS = { + "aiohappyeyeballs", + "aiohttp", + "aioice", + "aiortc", + "aiosignal", + "annotated_types", + "attrs", + "av", + "cffi", + "cryptography", + "dnspython", + "frozenlist", + "google_crc32c", + "idna", + "ifaddr", + "multidict", + "propcache", + "pycparser", + "pydantic", + "pydantic_core", + "pyee", + "pylibsrtp", + "pyopenssl", + "typing_extensions", + "typing_inspection", + "yarl", +} + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def archive_bytes(files): + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in sorted(files.items()): + info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0)) + info.external_attr = 0o644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + return stream.getvalue() + + +def runtime_payload(): + sdk = ROOT / "build/sdk" + lock = json.loads((PACKAGING / "sdk-lock.json").read_text()) + verify(sdk, lock) + native = ROOT / "build/native" + provenance = json.loads((native / "provenance.json").read_text()) + for name, expected in provenance["source"].items(): + if digest((ROOT / "native" / name).read_bytes()) != expected: + raise ValueError("Native adapter sources changed since compilation") + if provenance["sdk_lock_sha256"] != digest((PACKAGING / "sdk-lock.json").read_bytes()): + raise ValueError("Native adapter SDK input changed") + binary = (native / "libmissioncore_x4.so").read_bytes() + if digest(binary) != provenance["binary"]["sha256"] or provenance["abi"] != 1: + raise ValueError("Native adapter provenance mismatch") + files = {"lib/libmissioncore_x4.so": binary} + for item in lock["files"]: + if item["path"].startswith(("bin/", "lib/")): + files[item["path"]] = (sdk / item["path"]).read_bytes() + wheels = json.loads((PACKAGING / "python-lock.json").read_text())["wheels"] + for item in wheels: + source = REPOSITORY / "apps/node-agent/build/realsense-wheels" / item["name"] + data = source.read_bytes() + if source.is_symlink() or len(data) != item["bytes"] or digest(data) != item["sha256"]: + raise ValueError("Pinned Python wheel mismatch") + with zipfile.ZipFile(io.BytesIO(data)) as archive: + for entry in archive.infolist(): + if entry.is_dir(): + continue + name = entry.filename + path = PurePosixPath(name) + if ( + path.is_absolute() + or ".." in path.parts + or path.as_posix() != name + or "\\" in name + or ".data/" in name + or name.endswith(".pth") + or (entry.external_attr >> 16) & 0o170000 == 0o120000 + ): + raise ValueError("Unsupported Python wheel member") + target = "python/" + name + content = archive.read(entry) + if target in files and files[target] != content: + raise ValueError("Python runtime files collide") + files[target] = content + sdk_python = REPOSITORY / "packages/plugin-sdk/python/missioncore_plugin_sdk" + for path in sdk_python.rglob("*.py"): + files["python/missioncore_plugin_sdk/" + path.relative_to(sdk_python).as_posix()] = ( + path.read_bytes() + ) + payload = archive_bytes(files) + result = { + "schema": "missioncore.insta360.runtime-bundle/v1", + "version": VERSION, + "platform": "ubuntu-24.04-amd64", + "python": "3.12", + "sdk_lock_sha256": digest((PACKAGING / "sdk-lock.json").read_bytes()), + "python_lock_sha256": digest((PACKAGING / "python-lock.json").read_bytes()), + "native": provenance, + "runtime_source_sha256": { + path.name: digest(path.read_bytes()) for path in sorted((ROOT / "runtime").glob("*.py")) + }, + "payload_sha256": digest(payload), + "files": { + name: {"bytes": len(data), "sha256": digest(data)} + for name, data in sorted(files.items()) + }, + } + result["revision"] = digest(json.dumps(result, sort_keys=True).encode())[:24] + return payload, result + + +def build(output): + payload, bundle = runtime_payload() + files = [ + ("usr/share/mission-core-node/insta360/payload.zip", payload, 0o644), + ( + "usr/share/mission-core-node/insta360/bundle.json", + (json.dumps(bundle, indent=2) + "\n").encode(), + 0o644, + ), + ] + for name in ("sdk-lock.json", "python-lock.json"): + files.append( + ( + "usr/share/doc/mission-core-insta360-x4/" + name, + (PACKAGING / name).read_bytes(), + 0o644, + ) + ) + for name in ("layout.py", "bootstrap.py", "supervisor.py", "prepare.py"): + files.append( + ("usr/lib/mission-core-node/insta360/" + name, (PACKAGING / name).read_bytes(), 0o644) + ) + for path in sorted((ROOT / "runtime").glob("*.py")): + files.append( + ("usr/lib/mission-core-node/insta360/runtime/" + path.name, path.read_bytes(), 0o644) + ) + for path in sorted(PACKAGING.glob("*.service")): + files.append(("usr/lib/systemd/system/" + path.name, path.read_bytes(), 0o644)) + files.extend( + [ + ( + "usr/lib/udev/rules.d/70-mission-core-insta360.rules", + (PACKAGING / "70-mission-core-insta360.rules").read_bytes(), + 0o644, + ), + ( + "usr/share/polkit-1/rules.d/50-mission-core-insta360.rules", + (PACKAGING / "50-mission-core-insta360.rules").read_bytes(), + 0o644, + ), + ] + ) + provenance = { + "package": "mission-core-insta360-x4", + "version": VERSION, + "revision": bundle["revision"], + "files": {name: digest(data) for name, data, _ in files}, + "hardware_qualified": False, + "clean_image_qualified": False, + } + files.append( + ( + "usr/share/doc/mission-core-insta360-x4/provenance.json", + (json.dumps(provenance, indent=2) + "\n").encode(), + 0o644, + ) + ) + control = f"""Package: mission-core-insta360-x4 +Version: {VERSION} +Architecture: amd64 +Maintainer: NODE.DC local build +Section: admin +Priority: optional +Depends: mission-core-node (>= 0.8.16), mission-core-node (<< 0.9.0), + systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), adduser, udev, polkitd, + libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g +Description: Optional Insta360 X4 control and operator camera integration + Private USB instances and pinned offline runtime for Ubuntu 24.04 amd64. +""".encode() + controls = [("control", control, 0o644)] + [ + (name, (PACKAGING / name).read_bytes(), 0o755) + for name in ("preinst", "postinst", "prerm", "postrm") + ] + data = package(controls, files) + output.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open(output, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + return { + "file": str(output), + "bytes": len(data), + "sha256": digest(data), + "revision": bundle["revision"], + "version": VERSION, + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + print(json.dumps(build(args.output))) diff --git a/plugins/insta360-x4/packaging/build_native.py b/plugins/insta360-x4/packaging/build_native.py new file mode 100644 index 0000000..ef8199b --- /dev/null +++ b/plugins/insta360-x4/packaging/build_native.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Build the private X4 adapter through a versioned Linux build artifact. + +Input SDK bytes must already satisfy sdk-lock.json. No network, dependency +installation, SDK execution, device access or mutation of system directories. +""" + +import argparse +import hashlib +import json +import os +import platform +import shutil +import subprocess +import tempfile +from pathlib import Path + +from fetch_sdk import LOCK, ROOT, inspect_elf, verify + + +def build(sdk, destination): + if platform.system() != "Linux" or platform.machine() != "x86_64": + raise RuntimeError("Native compilation requires Linux x86_64 and the declared compiler") + lock = json.loads(LOCK.read_text()) + verify(sdk, lock) + if destination.exists(): + raise ValueError("Do not replace a previously built adapter") + destination.parent.mkdir(parents=True, exist_ok=True) + stage = Path(tempfile.mkdtemp(prefix=".native-", dir=destination.parent)) + env = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "SOURCE_DATE_EPOCH": "1788825600"} + try: + target = stage / "libmissioncore_x4.so" + subprocess.run( + [ + "/usr/bin/g++", + "-std=c++17", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + "-fPIC", + "-fvisibility=hidden", + "-shared", + "-pthread", + "-ffile-prefix-map=" + str(ROOT) + "=/source/insta360-x4", + "-I", + str(sdk / "include"), + str(ROOT / "native/bridge.cpp"), + "-L", + str(sdk / "lib"), + "-Wl,-z,defs,-z,relro,-z,now", + "-Wl,-rpath,$ORIGIN", + "-lCameraSDK", + "-o", + str(target), + ], + env=env, + check=True, + timeout=120, + ) + elf = inspect_elf(target) + if "libCameraSDK.so" not in elf["needed"]: + raise ValueError("Built adapter does not depend on the admitted SDK") + report = { + "schema": "missioncore.insta360.native-build/v1", + "abi": 1, + "sdk_lock_sha256": hashlib.sha256(LOCK.read_bytes()).hexdigest(), + "source": { + p.name: hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted((ROOT / "native").glob("*")) + if p.is_file() + }, + "compiler": subprocess.check_output( + ["/usr/bin/g++", "--version"], env=env, text=True + ).splitlines()[0], + "binary": { + "name": target.name, + "bytes": target.stat().st_size, + "sha256": hashlib.sha256(target.read_bytes()).hexdigest(), + }, + "elf": elf, + "sdk_executed": False, + "hardware_tested": False, + } + target.chmod(0o644) + (stage / "provenance.json").write_text(json.dumps(report, indent=2) + "\n") + os.rename(stage, destination) + return report + finally: + if stage.exists(): + shutil.rmtree(stage) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sdk", type=Path, default=ROOT / "build/sdk") + parser.add_argument("--output", type=Path, default=ROOT / "build/native") + args = parser.parse_args() + print(json.dumps(build(args.sdk.resolve(), args.output.resolve()), indent=2)) diff --git a/plugins/insta360-x4/packaging/build_native_entry.py b/plugins/insta360-x4/packaging/build_native_entry.py new file mode 100644 index 0000000..6568d2b --- /dev/null +++ b/plugins/insta360-x4/packaging/build_native_entry.py @@ -0,0 +1,9 @@ +"""Isolated interpreter bootstrap for the fixed, verified build module.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build_native import ROOT, build # noqa: E402 + +build(ROOT / "build/sdk", ROOT / "build/native") diff --git a/plugins/insta360-x4/packaging/build_owner_release.py b/plugins/insta360-x4/packaging/build_owner_release.py new file mode 100644 index 0000000..c7417c1 --- /dev/null +++ b/plugins/insta360-x4/packaging/build_owner_release.py @@ -0,0 +1,67 @@ +"""Wrap the qualified .deb and the fixed local installer into one owned artifact.""" + +import argparse +import hashlib +import json +import re +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def build(qualified): + expected = json.loads((qualified / "package-result.json").read_text()) + version = expected["version"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version): + raise ValueError("Invalid qualified version") + package_name = "mission-core-insta360-x4_" + version + "_amd64.deb" + package = (qualified / package_name).read_bytes() + if ( + len(package) != expected["bytes"] + or hashlib.sha256(package).hexdigest() != expected["sha256"] + ): + raise ValueError("Qualified package changed") + files = { + package_name: package, + **{ + name: (ROOT / "packaging" / name).read_bytes() + for name in ("install", "install_release.py") + }, + } + entrypoint = (ROOT / "packaging/owner_release_entry.py").read_bytes() + manifest = { + "schema": "missioncore.insta360.owner-release/v1", + "version": version, + "runtime_revision": expected["revision"], + "qualification_profile": "sdk-status-and-image-verification", + "qualification": "Ubuntu native build, cold imports, static closure, synthetic tests", + "hardware_qualified": False, + "clean_image_qualified": False, + "entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(), + "files": { + name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + for name, data in files.items() + }, + } + raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n" + ident = hashlib.sha256(raw).hexdigest()[:24] + output = ROOT / "build" / ("mission-core-x4-install-" + ident + ".pyz") + with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive: + for name, data in {"__main__.py": entrypoint, "release.json": raw, **files}.items(): + info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0)) + info.external_attr = 0o600 << 16 + archive.writestr(info, data) + output.chmod(0o600) + return { + "artifact": str(output), + "release_id": ident, + "bytes": output.stat().st_size, + "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), + } + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--qualified", type=Path, required=True) + print(json.dumps(build(parser.parse_args().qualified))) diff --git a/plugins/insta360-x4/packaging/build_package_artifact.py b/plugins/insta360-x4/packaging/build_package_artifact.py new file mode 100644 index 0000000..f99135b --- /dev/null +++ b/plugins/insta360-x4/packaging/build_package_artifact.py @@ -0,0 +1,84 @@ +"""Snapshot package inputs for compilation/qualification only on the Ubuntu Mini.""" + +import hashlib +import json +import sys +import zipfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build_deb import VERSION # noqa: E402 +from fetch_sdk import ROOT, verify # noqa: E402 + +REPOSITORY = ROOT.parents[1] + + +def build(): + sdk = json.loads((ROOT / "packaging/sdk-lock.json").read_text()) + verify(ROOT / "build/sdk", sdk) + paths = list((ROOT / "packaging").glob("*.py")) + paths += [ + p + for pattern in ("*.json", "*.service", "*.rules") + for p in (ROOT / "packaging").glob(pattern) + ] + paths += [ROOT / "packaging" / name for name in ("preinst", "postinst", "prerm", "postrm")] + paths += list((ROOT / "runtime").glob("*.py")) + list((ROOT / "tests").glob("*.py")) + paths += [ROOT / "native" / name for name in ("bridge.cpp", "bridge.h")] + paths += [ROOT / "build/native" / name for name in ("libmissioncore_x4.so", "provenance.json")] + paths += [ROOT / "build/sdk" / entry["path"] for entry in sdk["files"]] + wheels = json.loads((ROOT / "packaging/python-lock.json").read_text())["wheels"] + paths += [ + REPOSITORY / "apps/node-agent/build/realsense-wheels" / entry["name"] for entry in wheels + ] + 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 / name + for name in ( + "src/k1link/__init__.py", + "src/k1link/fleet/__init__.py", + "src/k1link/fleet/sensors.py", + "src/k1link/fleet/trust.py", + ) + ] + files = { + path.relative_to(REPOSITORY).as_posix(): path.read_bytes() for path in sorted(set(paths)) + } + entrypoint = (ROOT / "packaging/native_build_entry.py").read_bytes() + manifest = { + "schema": "missioncore.insta360.build-source/v1", + "version": "0.1.0", + "kind": "package", + "package_version": VERSION, + "entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(), + "files": { + name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + for name, data in files.items() + }, + } + raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n" + ident = hashlib.sha256(raw).hexdigest()[:24] + output = ROOT / "build" / ("mission-core-x4-package-build-" + ident + ".pyz") + with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive: + for name, data in { + "__main__.py": entrypoint, + "source.json": raw, + **{"source/" + name: data for name, data in files.items()}, + }.items(): + info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0)) + info.external_attr = 0o600 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + output.chmod(0o600) + return { + "artifact": str(output), + "source_id": ident, + "bytes": output.stat().st_size, + "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), + } + + +if __name__ == "__main__": + print(json.dumps(build())) diff --git a/plugins/insta360-x4/packaging/build_source_artifact.py b/plugins/insta360-x4/packaging/build_source_artifact.py new file mode 100644 index 0000000..d9ac9c7 --- /dev/null +++ b/plugins/insta360-x4/packaging/build_source_artifact.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Package exact native sources and locked SDK input for the Ubuntu build step.""" + +import hashlib +import json +import zipfile +from pathlib import Path + +from fetch_sdk import LOCK, ROOT, verify + + +def build(): + lock = json.loads(LOCK.read_text()) + sdk = ROOT / "build/sdk" + verify(sdk, lock) + names = [ + "native/bridge.cpp", + "native/bridge.h", + "native/tests/fake_sdk.cpp", + "native/tests/check_abi.py", + "packaging/build_native.py", + "packaging/build_native_entry.py", + "packaging/fetch_sdk.py", + "packaging/sdk-lock.json", + ] + names += ["build/sdk/" + entry["path"] for entry in lock["files"]] + files = {name: (ROOT / name).read_bytes() for name in sorted(names)} + entrypoint = (Path(__file__).with_name("native_build_entry.py")).read_bytes() + manifest = { + "schema": "missioncore.insta360.build-source/v1", + "version": "0.1.0", + "entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(), + "files": { + name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} + for name, data in files.items() + }, + } + content = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n" + ident = hashlib.sha256(content).hexdigest()[:24] + output = ROOT / "build" / ("mission-core-x4-build-" + ident + ".pyz") + entries = {"__main__.py": entrypoint, "source.json": content} + entries.update({"source/" + name: data for name, data in files.items()}) + with ( + output.open("xb") as stream, + zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive, + ): + for name, data in entries.items(): + info = zipfile.ZipInfo(name, date_time=(2026, 9, 8, 0, 0, 0)) + info.external_attr = 0o600 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data) + output.chmod(0o600) + return { + "artifact": str(output), + "bytes": output.stat().st_size, + "sha256": hashlib.sha256(output.read_bytes()).hexdigest(), + "source_id": ident, + } + + +if __name__ == "__main__": + print(json.dumps(build())) diff --git a/plugins/insta360-x4/packaging/check_package_entry.py b/plugins/insta360-x4/packaging/check_package_entry.py new file mode 100644 index 0000000..7d9d002 --- /dev/null +++ b/plugins/insta360-x4/packaging/check_package_entry.py @@ -0,0 +1,84 @@ +"""Cold Python imports and synthetic installer/control tests, no vendor execution.""" + +import hashlib +import importlib +import io +import json +import shutil +import subprocess +import sys +import tarfile +import unittest +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "packaging")] +from build_deb import VERSION # noqa: E402 +from fetch_sdk import inspect_elf # noqa: E402 +from prepare import entries # noqa: E402 + +package = ROOT / "build" / ("mission-core-insta360-x4_" + VERSION + "_amd64.deb") +data = subprocess.check_output(["/usr/bin/dpkg-deb", "--fsys-tarfile", str(package)], timeout=30) +with tarfile.open(fileobj=io.BytesIO(data)) as archive: + payload = archive.extractfile("usr/share/mission-core-node/insta360/payload.zip").read() + bundle = json.load(archive.extractfile("usr/share/mission-core-node/insta360/bundle.json")) +if hashlib.sha256(payload).hexdigest() != bundle["payload_sha256"]: + raise ValueError("Package payload hash mismatch") +stage = ROOT / "build/cold-runtime" +stage.mkdir(mode=0o700) +try: + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + for name, content in entries(archive, bundle): + path = stage / name + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + path.write_bytes(content) + python = stage / "python" + sys.path.insert(0, str(python)) + versions = {} + for name in ("aiohttp", "aiortc", "av", "pydantic", "cryptography", "cffi"): + module = importlib.import_module(name) + if not Path(module.__file__).is_relative_to(python): + raise RuntimeError("An undeclared system Python package was imported") + versions[name] = getattr(module, "__version__", "available") + libraries = [ + path for path in stage.rglob("*") if path.is_file() and path.read_bytes()[:4] == b"\x7fELF" + ] + provided = {path.name for path in libraries} + system = { + "libc.so.6", + "libm.so.6", + "libmvec.so.1", + "libpthread.so.0", + "libdl.so.2", + "librt.so.1", + "libresolv.so.2", + "libgcc_s.so.1", + "libstdc++.so.6", + "ld-linux-x86-64.so.2", + "libz.so.1", + } + closure = {str(path.relative_to(stage)): inspect_elf(path)["needed"] for path in libraries} + missing = { + name for needed in closure.values() for name in needed if name not in provided | system + } + if missing: + raise RuntimeError("Undeclared OS library dependencies: " + ", ".join(sorted(missing))) + (ROOT / "build/runtime-check.json").write_text( + json.dumps( + { + "python_imports": versions, + "elf_dependencies": closure, + "declared_os_libraries": sorted(system), + "sdk_executed": False, + "clean_os_image_tested": False, + }, + indent=2, + ) + + "\n" + ) + suite = unittest.defaultTestLoader.discover(str(ROOT / "tests"), pattern="check_*.py") + if not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful(): + raise RuntimeError("Package qualification tests failed") +finally: + shutil.rmtree(stage) diff --git a/plugins/insta360-x4/packaging/fetch_sdk.py b/plugins/insta360-x4/packaging/fetch_sdk.py new file mode 100644 index 0000000..e9d297b --- /dev/null +++ b/plugins/insta360-x4/packaging/fetch_sdk.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Build-time SDK acquisition. Never installed or executed on an operator Node. + +Pin every byte from a public mirror; do not execute SDK code to inspect it. +The source lock is provenance, not a claim of vendor authenticity or licensing. +""" + +import argparse +import hashlib +import json +import re +import shutil +import struct +import tempfile +import urllib.request +from pathlib import Path, PurePosixPath + +ROOT = Path(__file__).resolve().parents[1] +LOCK = Path(__file__).with_name("sdk-lock.json") + + +def entries(lock): + if lock["schema"] != "missioncore.insta360.sdk-source/v1": + raise ValueError("Unsupported SDK lock") + if not re.fullmatch(r"[0-9a-f]{40}", lock["commit"]): + raise ValueError("SDK source must be an immutable commit") + seen = set() + for entry in lock["files"]: + path = PurePosixPath(entry["path"]) + if ( + path.is_absolute() + or ".." in path.parts + or "\\" in entry["path"] + or path.as_posix() != entry["path"] + or entry["path"] in seen + or not re.fullmatch(r"[0-9a-f]{64}", entry["sha256"]) + or not 0 < entry["bytes"] <= 32 * 1024 * 1024 + ): + raise ValueError("Invalid SDK payload entry") + seen.add(entry["path"]) + yield entry + + +def verified_bytes(data, entry): + if len(data) != entry["bytes"] or hashlib.sha256(data).hexdigest() != entry["sha256"]: + raise ValueError("SDK checksum mismatch: " + entry["path"]) + return data + + +def inspect_elf(path): + """Parse ELF64 sections without loading the library (never ldd/dlopen).""" + data = path.read_bytes() + if ( + len(data) < 64 + or data[:7] != b"\x7fELF\x02\x01\x01" + or struct.unpack_from(" len(data): + raise ValueError("Invalid ELF section table") + sections = [struct.unpack_from("= count: + raise ValueError("Invalid ELF string table") + strings = sections[section[6]] + table = data[strings[4] : strings[4] + strings[5]] + if section[4] + section[5] > len(data) or section[5] % 16: + raise ValueError("Invalid ELF dynamic table") + for at in range(section[4], section[4] + section[5], 16): + tag, value = struct.unpack_from("&1 | /usr/bin/tee -a "$mc_x4_release_dir/install-output.log" +mc_x4_install_result=${PIPESTATUS[0]} +printf '\nКод завершения: %s\nНажмите Enter, чтобы закрыть окно.\n' "$mc_x4_install_result" +read -r mc_x4_close +exit "$mc_x4_install_result" diff --git a/plugins/insta360-x4/packaging/install_release.py b/plugins/insta360-x4/packaging/install_release.py new file mode 100644 index 0000000..c1e2498 --- /dev/null +++ b/plugins/insta360-x4/packaging/install_release.py @@ -0,0 +1,263 @@ +"""Owner-facing, versioned installer; first SDK Open belongs to fixed prepare. + +Invoke through the release's local Ubuntu terminal. The OS password is never +accepted by this script or sent to Mission Core. No arbitrary commands/URLs. +""" + +import hashlib +import http.client +import json +import os +import re +import socket +import subprocess +import sys +import time +import uuid +from contextlib import suppress +from datetime import UTC, datetime, timedelta +from pathlib import Path + +STAGING = Path("/var/tmp/mission-core-x4-installs") +SERVICES = ( + "mission-core-node.service", + "mission-core-k1.service", + "mission-core-realsense.service", +) + + +def private(path): + path.mkdir(mode=0o700, exist_ok=True) + info = path.lstat() + if path.is_symlink() or not path.is_dir() or info.st_uid != 0 or info.st_mode & 0o077: + raise RuntimeError("Installer staging is not root-owned and private") + + +def request(path, operation=None): + timeout = 55 if operation else 3 + client = http.client.HTTPConnection("driver", timeout=timeout) + client.sock = socket.socket(socket.AF_UNIX) + client.sock.settimeout(timeout) + try: + client.sock.connect(str(path)) + if operation is None: + client.request("GET", "/snapshot") + else: + client.request( + "POST", "/operation", json.dumps(operation), {"Content-Type": "application/json"} + ) + response = client.getresponse() + content = response.read(65537) + if response.status != 200 or len(content) > 65536: + raise RuntimeError("Camera status is unavailable") + return json.loads(content) + finally: + client.close() + + +def main(): + if os.geteuid() or sys.argv[1:]: + raise RuntimeError("Запустите установщик в локальном окне Ubuntu через sudo.") + os.umask(0o077) + source = Path(__file__).resolve().parent + manifest = json.loads((source / "release.json").read_text()) + if manifest["schema"] != "missioncore.insta360.owner-release/v1": + raise ValueError("Unsupported release") + version = manifest["version"] + revision = manifest["runtime_revision"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version) or not re.fullmatch( + r"[0-9a-f]{24}", revision + ): + raise ValueError("Invalid release version") + if manifest["qualification_profile"] != "sdk-status-and-image-verification": + raise ValueError("Unsupported qualification profile") + package = "mission-core-insta360-x4_" + version + "_amd64.deb" + admitted = manifest["files"] + if set(admitted) != {package, "install_release.py", "install"}: + raise ValueError("Unexpected installer contents") + for name, expected in admitted.items(): + data = (source / name).read_bytes() + if len(data) != expected["bytes"] or hashlib.sha256(data).hexdigest() != expected["sha256"]: + raise ValueError("Установщик повреждён. Контрольная сумма не совпала.") + private(STAGING) + identifier = uuid.uuid4().hex + folder = STAGING / identifier + private(folder) + # Snapshot the exact admitted bytes to root-only staging before APT, so + # it never consumes a mutable package from the operator's Downloads path. + data = (source / package).read_bytes() + if hashlib.sha256(data).hexdigest() != admitted[package]["sha256"]: + raise ValueError("Package changed before staging") + (folder / package).write_bytes(data) + report = { + "schema": "missioncore.insta360.install-run/v1", + "session_id": identifier, + "started_at": datetime.now(UTC).isoformat(), + "monotonic_started": time.monotonic(), + "release_sha256": hashlib.sha256((source / "release.json").read_bytes()).hexdigest(), + "package_sha256": admitted[package]["sha256"], + "state": "running", + "scope": "install-profile-SDK-status-and-bounded-image-verification", + "steps": [], + } + env = { + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + "LANG": "C.UTF-8", + "DEBIAN_FRONTEND": "noninteractive", + } + + def publish(): + (folder / "report.json").write_text(json.dumps(report, indent=2) + "\n") + + def run(step, command, timeout=180): + report["steps"].append( + {"id": step, "state": "running", "started_at": datetime.now(UTC).isoformat()} + ) + publish() + result = subprocess.run(command, env=env, capture_output=True, timeout=timeout) + (folder / (step + ".stdout")).write_bytes(result.stdout) + (folder / (step + ".stderr")).write_bytes(result.stderr) + report["steps"][-1].update( + state="complete" if result.returncode == 0 else "error", exit_code=result.returncode + ) + publish() + if result.returncode: + raise RuntimeError("Не завершён этап установки: " + step) + return result.stdout + + publish() + try: + # These remain private to the authenticated owner; the terminal wrapper + # saves stdout with umask 077. No camera identifiers enter public logs. + previous = sorted(STAGING.glob("*/report.json"), key=lambda p: p.stat().st_mtime) + for path in previous[-6:]: + if path.parent != folder: + print("MISSION_CORE_X4_PREVIOUS " + path.read_text().replace("\n", ""), flush=True) + baseline = run( + "baseline", + [ + "/usr/bin/systemctl", + "show", + *SERVICES, + "-p", + "Id", + "-p", + "ActiveState", + "-p", + "NRestarts", + ], + ) + report["existing_services"] = baseline.decode().splitlines() + run( + "apt-plan", + ["/usr/bin/apt-get", "--simulate", "--no-remove", "install", str(folder / package)], + ) + run( + "package", + ["/usr/bin/apt-get", "install", "-y", "--no-remove", str(folder / package)], + timeout=None, + ) + # This is the identical installed model job started by Node's local or + # paired remote prepare command. No SDK demo, root SDK or manual grant. + preparation = Path("/var/lib/mission-core-insta360/preparation.json") + prepared = json.loads(preparation.read_text()) if preparation.exists() else {} + active = Path("/var/lib/mission-core-insta360/active.path") + # An upgrade's postinst already executes this same fixed preparation. + # Do not race its asynchronously connecting workers with a second run. + if not ( + prepared.get("state") == "complete" + and prepared.get("revision") == revision + and active.exists() + and active.read_text().strip() == revision + ): + run( + "prepare", + ["/usr/bin/systemctl", "start", "mission-core-node-insta360-x4-prepare.service"], + ) + deadline = time.monotonic() + 50 + while True: + values = [] + for path in Path("/run/mission-core-x4-instances").glob("instax4_*/driver.sock"): + if not re.fullmatch(r"instax4_[0-9a-f]{32}", path.parent.name): + continue + with suppress(OSError, ValueError, RuntimeError, http.client.HTTPException): + values.append(request(path)) + if values and all(item.get("prepared") and item.get("online") for item in values): + break + if time.monotonic() >= deadline: + report["camera_status"] = values + raise RuntimeError( + "Пакет установлен, но подключение X4 не подтверждено. " + "Проверьте питание и USB-режим камеры." + ) + time.sleep(1) + report["camera_status"] = values + if len(values) != 1: + raise RuntimeError("Для этой аппаратной проверки требуется ровно одна X4.") + camera = values[0] + if not re.fullmatch(r"instax4_[0-9a-f]{32}", camera["id"]): + raise RuntimeError("Camera identity is invalid") + now = datetime.now(UTC) + operation = "op_" + uuid.uuid4().hex + command = { + "api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", + "kind": "OperationRequest", + "operation_id": operation, + "idempotency_key": operation, + "session": {"device_id": camera["id"], "session_id": camera["session_id"]}, + "action_id": "verify", + "requested_at": now.isoformat(), + "deadline_at": (now + timedelta(seconds=60)).isoformat(), + "parameters": {}, + } + report["image_verification"] = {"request": command, "state": "running"} + publish() + result = request( + Path("/run/mission-core-x4-instances") / camera["id"] / "driver.sock", command + ) + report["image_verification"].update(state=result.get("state"), result=result) + publish() + if ( + result.get("state") != "complete" + or result.get("result", {}).get("verified") is not True + ): + raise RuntimeError("SDK подключён, но проверка изображения не завершилась успешно.") + after = run( + "existing-services-after", + [ + "/usr/bin/systemctl", + "show", + *SERVICES, + "-p", + "Id", + "-p", + "ActiveState", + "-p", + "NRestarts", + ], + ) + report["existing_services_unchanged"] = after == baseline + report["state"] = "complete" + print("Пакет X4 установлен. SDK подключился; получение изображения подтверждено.") + print("WebRTC и команды записи остаются отдельными проверками.") + except Exception as error: + report["state"] = "error" + report["error"] = str(error)[:300] + raise + finally: + report["finished_at"] = datetime.now(UTC).isoformat() + report["duration_seconds"] = time.monotonic() - report["monotonic_started"] + publish() + # Retain bounded private evidence and remove only the installer-owned + # temporary package copy. Runtime/journals remain owned by the .deb. + (folder / package).unlink() + print("MISSION_CORE_X4_RESULT " + json.dumps(report, ensure_ascii=False), flush=True) + print("Отчёт установки:", folder / "report.json") + + +if __name__ == "__main__": + try: + main() + except Exception as error: + print(str(error), file=sys.stderr) + sys.exit(1) diff --git a/plugins/insta360-x4/packaging/layout.py b/plugins/insta360-x4/packaging/layout.py new file mode 100644 index 0000000..3bcff74 --- /dev/null +++ b/plugins/insta360-x4/packaging/layout.py @@ -0,0 +1,60 @@ +"""Fixed root-owned locations shared by installer and runtime bootstrap.""" + +import json +import os +from pathlib import Path + +CODE = Path("/usr/lib/mission-core-node/insta360") +SHARE = Path("/usr/share/mission-core-node/insta360") +STATE = Path("/var/lib/mission-core-insta360") +CONTROL = Path("/run/mission-core-x4-control") + + +def trusted(path, directory=False): + info = path.lstat() + if path.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError("Untrusted camera installation path") + if path.is_dir() != directory: + raise RuntimeError("Unexpected camera installation entry") + return path + + +def manifest(): + trusted(SHARE, True) + return json.loads(trusted(SHARE / "bundle.json").read_text()) + + +def active(): + trusted(STATE, True) + ident = trusted(STATE / "active.path").read_text().strip() + if len(ident) != 24 or any(c not in "0123456789abcdef" for c in ident): + raise RuntimeError("Invalid installed runtime revision") + return trusted(STATE / "runtime", True) / ident + + +def directory(path, mode=0o755): + path.mkdir(mode=mode, exist_ok=True) + trusted(path, True) + + +def write(path, data, mode=0o644): + import tempfile + + if path.exists() or path.is_symlink(): + trusted(path) + fd, temporary = tempfile.mkstemp(prefix=".x4-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + os.fchmod(stream.fileno(), mode) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + folder = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(folder) + finally: + os.close(folder) + finally: + if os.path.exists(temporary): + os.unlink(temporary) diff --git a/plugins/insta360-x4/packaging/mission-core-insta360-supervisor.service b/plugins/insta360-x4/packaging/mission-core-insta360-supervisor.service new file mode 100644 index 0000000..82d6bc1 --- /dev/null +++ b/plugins/insta360-x4/packaging/mission-core-insta360-supervisor.service @@ -0,0 +1,26 @@ +[Unit] +Description=Mission Core X4 USB instance supervisor +After=systemd-udevd.service +ConditionPathExists=/var/lib/mission-core-insta360/active.path +[Service] +Type=simple +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/supervisor.py +RuntimeDirectory=mission-core-x4-control +RuntimeDirectoryMode=0755 +RuntimeDirectoryPreserve=yes +UMask=0022 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ReadWritePaths=/run/systemd/system +RestrictAddressFamilies=AF_UNIX +CapabilityBoundingSet= +TasksMax=16 +MemoryMax=96M +Restart=no +[Install] +WantedBy=multi-user.target diff --git a/plugins/insta360-x4/packaging/mission-core-insta360.service b/plugins/insta360-x4/packaging/mission-core-insta360.service new file mode 100644 index 0000000..dc43d93 --- /dev/null +++ b/plugins/insta360-x4/packaging/mission-core-insta360.service @@ -0,0 +1,31 @@ +[Unit] +Description=Mission Core Insta360 model broker +ConditionPathExists=/var/lib/mission-core-insta360/active.path +[Service] +Type=simple +User=mission-core-insta360 +Group=mission-core-node +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/bootstrap.py broker +RuntimeDirectory=mission-core-insta360 +RuntimeDirectoryMode=0750 +StateDirectory=mission-core-insta360-broker +StateDirectoryMode=0700 +UMask=0007 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +CapabilityBoundingSet= +TasksMax=192 +MemoryMax=1G +CPUQuota=150% +Restart=on-failure +RestartSec=3 +[Install] +WantedBy=multi-user.target diff --git a/plugins/insta360-x4/packaging/mission-core-node-insta360-x4-prepare.service b/plugins/insta360-x4/packaging/mission-core-node-insta360-x4-prepare.service new file mode 100644 index 0000000..2ff8684 --- /dev/null +++ b/plugins/insta360-x4/packaging/mission-core-node-insta360-x4-prepare.service @@ -0,0 +1,8 @@ +[Unit] +Description=Mission Core fixed Insta360 X4 preparation +After=systemd-udevd.service +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py +TimeoutStartSec=180 +UMask=0022 diff --git a/plugins/insta360-x4/packaging/native_build_entry.py b/plugins/insta360-x4/packaging/native_build_entry.py new file mode 100644 index 0000000..cda60dd --- /dev/null +++ b/plugins/insta360-x4/packaging/native_build_entry.py @@ -0,0 +1,203 @@ +"""Entry point embedded in the versioned, self-verifying Ubuntu build artifact. + +No system install, SDK loading, discovery, camera command or root execution. +All writes belong to this private build directory and its immutable archive. +""" + +import hashlib +import io +import json +import os +import platform +import re +import resource +import shutil +import subprocess +import sys +import tarfile +import time +import zipfile +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath + +BUILD_ROOT = Path("/var/tmp/mission-core-x4-builds") + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def private_directory(path): + path.mkdir(mode=0o700, exist_ok=True) + info = path.lstat() + if ( + path.is_symlink() + or not path.is_dir() + or info.st_uid != os.geteuid() + or info.st_mode & 0o077 + ): + raise RuntimeError("Build directory is not private and owned") + + +def main(): + if os.geteuid() == 0 or platform.system() != "Linux" or platform.machine() != "x86_64": + raise RuntimeError("Use the unprivileged Ubuntu x86_64 build account") + os.umask(0o077) + started, monotonic = datetime.now(UTC).isoformat(), time.monotonic() + artifact = Path(sys.argv[0]).resolve() + with zipfile.ZipFile(artifact) as archive: + raw_manifest = archive.read("source.json") + manifest = json.loads(raw_manifest) + ident = digest(raw_manifest)[:24] + if manifest["schema"] != "missioncore.insta360.build-source/v1": + raise ValueError("Unsupported build artifact") + if digest(archive.read("__main__.py")) != manifest["entrypoint_sha256"]: + raise ValueError("Build entry point differs from the manifest") + private_directory(BUILD_ROOT) + folder = BUILD_ROOT / ident + private_directory(folder) + output = folder / "result.tar.gz" + if len(sys.argv) > 1: + if sys.argv[1:] != ["--clean"]: + raise ValueError("No build commands or paths are accepted") + shutil.rmtree(folder) + print(json.dumps({"cleaned": ident})) + return + if output.exists(): + print( + json.dumps( + {"result": str(output), "sha256": digest(output.read_bytes()), "reused": True} + ) + ) + return + if (folder / "source").exists(): + raise RuntimeError( + "Incomplete prior attempt; preserve evidence and use --clean before retry" + ) + source = folder / "source" + source.mkdir(mode=0o700) + allowed = {"__main__.py", "source.json"} | {"source/" + p for p in manifest["files"]} + if len(archive.namelist()) != len(allowed) or set(archive.namelist()) != allowed: + raise ValueError("Unexpected source archive entries") + for relative, expected in manifest["files"].items(): + path = PurePosixPath(relative) + if ( + path.is_absolute() + or ".." in path.parts + or path.as_posix() != relative + or "\\" in relative + ): + raise ValueError("Unsafe build payload path") + entry = archive.getinfo("source/" + relative) + if entry.file_size != expected["bytes"] or entry.file_size > 100 * 1024 * 1024: + raise ValueError("Invalid source size") + data = archive.read(entry) + if digest(data) != expected["sha256"]: + raise ValueError("Source hash mismatch") + target = source / relative + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + target.write_bytes(data) + (folder / "intent.json").write_text( + json.dumps( + { + "schema": "missioncore.insta360.build-run/v1", + "id": ident, + "started_at": started, + "monotonic_started": monotonic, + "artifact_sha256": digest(artifact.read_bytes()), + "source_manifest_sha256": digest(raw_manifest), + "scope": "package-and-synthetic-tests-only" + if manifest.get("kind") == "package" + else "compile-and-synthetic-tests-only", + "state": "running", + }, + indent=2, + ) + + "\n" + ) + # Bound compiler consumption on the shared Mini. This does not alter OS, + # service, user or shell configuration; limits apply to this process tree. + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + resource.setrlimit(resource.RLIMIT_CPU, (180, 180)) + resource.setrlimit(resource.RLIMIT_AS, (1024 * 1024 * 1024, 1024 * 1024 * 1024)) + os.nice(10) + env = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "PYTHONDONTWRITEBYTECODE": "1"} + report = json.loads((folder / "intent.json").read_text()) + package_build = manifest.get("kind") == "package" + if package_build: + plugin = source / "plugins/insta360-x4" + version = manifest["package_version"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version): + raise ValueError("Invalid package build version") + package_name = "mission-core-insta360-x4_" + version + "_amd64.deb" + jobs = ( + ( + "package-build", + ["/usr/bin/python3", "-I", str(plugin / "packaging/package_build_entry.py")], + ), + ( + "package-tests", + ["/usr/bin/python3", "-I", str(plugin / "packaging/check_package_entry.py")], + ), + ) + outputs = [ + ( + plugin / "build" / package_name, + package_name, + ), + (plugin / "build/package-result.json", "package-result.json"), + (plugin / "build/runtime-check.json", "runtime-check.json"), + (folder / "package-tests.stderr", "package-tests.txt"), + ] + else: + jobs = ( + ( + "native-build", + ["/usr/bin/python3", "-I", str(source / "packaging/build_native_entry.py")], + ), + ( + "synthetic-abi-tests", + ["/usr/bin/python3", "-I", str(source / "native/tests/check_abi.py"), "-v"], + ), + ) + outputs = [ + (source / "build/native/libmissioncore_x4.so", "libmissioncore_x4.so"), + (source / "build/native/provenance.json", "provenance.json"), + (folder / "synthetic-abi-tests.stderr", "synthetic-abi-tests.txt"), + ] + outputs.append((folder / "report.json", "build-report.json")) + try: + for name, command in jobs: + result = subprocess.run(command, cwd=source, env=env, capture_output=True, timeout=150) + (folder / (name + ".stdout")).write_bytes(result.stdout) + (folder / (name + ".stderr")).write_bytes(result.stderr) + if result.returncode: + raise RuntimeError(name + " failed; inspect the private build report") + report.update( + state="complete", + finished_at=datetime.now(UTC).isoformat(), + duration_seconds=time.monotonic() - monotonic, + ) + (folder / "report.json").write_text(json.dumps(report, indent=2) + "\n") + data = io.BytesIO() + with tarfile.open(fileobj=data, mode="w:gz") as archive: + for path, name in outputs: + archive.add(path, arcname=name, recursive=False) + output.write_bytes(data.getvalue()) + print( + json.dumps( + {"result": str(output), "sha256": digest(output.read_bytes()), "state": "complete"} + ) + ) + except Exception: + report.update( + state="error", + finished_at=datetime.now(UTC).isoformat(), + duration_seconds=time.monotonic() - monotonic, + ) + (folder / "report.json").write_text(json.dumps(report, indent=2) + "\n") + raise + + +if __name__ == "__main__": + main() diff --git a/plugins/insta360-x4/packaging/owner_release_entry.py b/plugins/insta360-x4/packaging/owner_release_entry.py new file mode 100644 index 0000000..d3a2a1a --- /dev/null +++ b/plugins/insta360-x4/packaging/owner_release_entry.py @@ -0,0 +1,149 @@ +"""Stage, inspect or open the fixed Ubuntu installer; never collect passwords.""" + +import hashlib +import json +import os +import platform +import re +import subprocess +import sys +import zipfile +from pathlib import Path + +PROFILES = { + "missioncore.insta360.owner-release/v1": ( + "mission-core-insta360-x4", + "/var/tmp/mission-core-x4-releases", + "Mission Core · Insta360 X4", + ), + "missioncore.node.owner-release/v1": ( + "mission-core-node", + "/var/tmp/mission-core-node-releases", + "Mission Core Node", + ), +} + + +def private(path): + path.mkdir(mode=0o700, exist_ok=True) + info = path.lstat() + if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077: + raise RuntimeError("Release directory is not private and owned") + + +def main(): + if os.geteuid() == 0 or platform.system() != "Linux" or platform.machine() != "x86_64": + raise RuntimeError("Откройте установщик обычным пользователем на Ubuntu amd64.") + if sys.argv[1:] not in (["--stage"], ["--plan"], ["--launch"]): + raise ValueError("Use --stage, --plan or --launch") + os.umask(0o077) + with zipfile.ZipFile(sys.argv[0]) as archive: + raw = archive.read("release.json") + manifest = json.loads(raw) + if manifest.get("schema") not in PROFILES: + raise ValueError("Invalid release schema") + package_id, root, title = PROFILES[manifest["schema"]] + version = manifest["version"] + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version): + raise ValueError("Invalid release version") + package = package_id + "_" + version + "_amd64.deb" + if hashlib.sha256(archive.read("__main__.py")).hexdigest() != manifest["entrypoint_sha256"]: + raise ValueError("Release entry point changed") + allowed = { + "release.json", + "__main__.py", + "install", + "install_release.py", + package, + } + if set(archive.namelist()) != allowed or len(archive.namelist()) != len(allowed): + raise ValueError("Invalid release contents") + if set(manifest["files"]) != allowed - {"release.json", "__main__.py"}: + raise ValueError("Invalid release manifest") + identifier = hashlib.sha256(raw).hexdigest()[:24] + private(Path(root)) + folder = Path(root) / identifier + private(folder) + files = {name: archive.read(name) for name in manifest["files"]} + for name, data in files.items(): + expected = manifest["files"][name] + if ( + len(data) != expected["bytes"] + or hashlib.sha256(data).hexdigest() != expected["sha256"] + ): + raise ValueError("Release payload changed") + files["release.json"] = raw + for name, data in files.items(): + path = folder / name + if path.is_symlink(): + raise ValueError("Unexpected release symlink") + if path.exists(): + if path.read_bytes() != data: + raise ValueError("Existing release was modified") + else: + with path.open("xb") as stream: + stream.write(data) + path.chmod(0o700 if name == "install" else 0o600) + print(json.dumps({"release_id": identifier, "directory": str(folder)}), flush=True) + if sys.argv[1] == "--plan": + result = subprocess.run( + [ + "/usr/bin/apt-get", + "--simulate", + "--no-remove", + "install", + str(folder / package), + ], + capture_output=True, + text=True, + timeout=45, + ) + (folder / "apt-plan.stdout").write_text(result.stdout) + (folder / "apt-plan.stderr").write_text(result.stderr) + print(result.stdout) + if result.returncode: + raise RuntimeError("APT plan failed; inspect the release's private report") + elif sys.argv[1] == "--launch": + environment = dict(os.environ) + result = subprocess.run( + ["/usr/bin/systemctl", "--user", "show-environment"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + # Only graphical session coordinates are admitted. Other user-service + # environment values are neither passed on nor printed in evidence. + for line in result.stdout.splitlines(): + key, separator, value = line.partition("=") + if separator and key in { + "DISPLAY", + "WAYLAND_DISPLAY", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", + "XAUTHORITY", + }: + environment[key] = value + with ( + (folder / "launcher.stdout").open("ab") as out, + (folder / "launcher.stderr").open("ab") as err, + ): + process = subprocess.Popen( + [ + "/usr/bin/gnome-terminal", + "--wait", + "--title=" + title + " · " + version, + "--", + str(folder / "install"), + ], + env=environment, + stdout=out, + stderr=err, + start_new_session=True, + ) + (folder / "launch.json").write_text(json.dumps({"pid": process.pid}) + "\n") + print("Окно установщика открывается в локальном сеансе Ubuntu.") + + +if __name__ == "__main__": + main() diff --git a/plugins/insta360-x4/packaging/package_build_entry.py b/plugins/insta360-x4/packaging/package_build_entry.py new file mode 100644 index 0000000..564c562 --- /dev/null +++ b/plugins/insta360-x4/packaging/package_build_entry.py @@ -0,0 +1,12 @@ +"""Fixed package-build entry point within the versioned Ubuntu source artifact.""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from build_deb import ROOT, VERSION, build # noqa: E402 + +result = build(ROOT / "build" / ("mission-core-insta360-x4_" + VERSION + "_amd64.deb")) +(ROOT / "build/package-result.json").write_text(json.dumps(result, indent=2) + "\n") +print(json.dumps(result)) diff --git a/plugins/insta360-x4/packaging/postinst b/plugins/insta360-x4/packaging/postinst new file mode 100644 index 0000000..d3cdcf6 --- /dev/null +++ b/plugins/insta360-x4/packaging/postinst @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu +if [ "$1" = configure ]; then + if ! getent group mission-core-x4-usb >/dev/null; then + addgroup --system mission-core-x4-usb + fi + if ! getent passwd mission-core-insta360 >/dev/null; then + adduser --system --home /var/lib/mission-core-insta360 --no-create-home --disabled-login --ingroup mission-core-node mission-core-insta360 + fi + if [ -d /run/systemd/system ]; then + systemctl daemon-reload + if [ -f /var/lib/mission-core-insta360/active.path ]; then + /usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py + fi + fi +fi diff --git a/plugins/insta360-x4/packaging/postrm b/plugins/insta360-x4/packaging/postrm new file mode 100644 index 0000000..eb9ac6a --- /dev/null +++ b/plugins/insta360-x4/packaging/postrm @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +case "$1" in + remove|purge) + if [ -d /run/systemd/system ]; then + systemctl daemon-reload + udevadm control --reload-rules + fi + ;; +esac +# Device state and operation receipts are retained. No SD files are removed. diff --git a/plugins/insta360-x4/packaging/preinst b/plugins/insta360-x4/packaging/preinst new file mode 100644 index 0000000..779e137 --- /dev/null +++ b/plugins/insta360-x4/packaging/preinst @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +if [ -f /usr/lib/mission-core-node/insta360/prepare.py ]; then + /usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py --quiesce +fi diff --git a/plugins/insta360-x4/packaging/prepare.py b/plugins/insta360-x4/packaging/prepare.py new file mode 100644 index 0000000..d532fd0 --- /dev/null +++ b/plugins/insta360-x4/packaging/prepare.py @@ -0,0 +1,231 @@ +"""Idempotent fixed profile preparation. All Ubuntu prerequisites have an owner.""" + +import fcntl +import grp +import hashlib +import json +import os +import platform +import shutil +import subprocess +import sys +import time +import uuid +import zipfile +from contextlib import contextmanager +from pathlib import Path, PurePosixPath + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from layout import SHARE, STATE, directory, manifest, trusted, write # noqa: E402 +from runtime.http import request # noqa: E402 + +STEPS = [ + ("platform", "Проверка совместимости системы"), + ("payload", "Проверка встроенного драйвера"), + ("runtime", "Развёртывание драйвера"), + ("access", "Настройка доступа к камере"), + ("service", "Запуск службы камеры"), +] +SOCKET = Path("/run/mission-core-insta360/driver.sock") +INSTANCES = Path("/run/mission-core-x4-instances") + + +def run(*args): + subprocess.run(args, check=True, timeout=45, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + + +def assert_safe(): + if SOCKET.exists(): + if request(SOCKET, "/prepare-safe", timeout=5).get("safe") is not True: + raise RuntimeError("Остановите просмотр и запись X4 перед обновлением драйвера.") + elif list(INSTANCES.glob("instax4_*")): + raise RuntimeError("Состояние X4 неизвестно. Подготовка не изменяла службы камеры.") + + +def entries(archive, bundle): + expected = bundle["files"] + if len(archive.infolist()) != len(expected) or set(archive.namelist()) != set(expected): + raise RuntimeError("Состав встроенного драйвера изменён.") + for name, info in expected.items(): + path = PurePosixPath(name) + entry = archive.getinfo(name) + if ( + path.is_absolute() + or ".." in path.parts + or path.as_posix() != name + or "\\" in name + or entry.is_dir() + or entry.file_size != info["bytes"] + or entry.file_size > 100 * 1024 * 1024 + ): + raise RuntimeError("Недопустимый файл драйвера.") + data = archive.read(entry) + if hashlib.sha256(data).hexdigest() != info["sha256"]: + raise RuntimeError("Контрольная сумма драйвера не совпала.") + yield name, data + + +def install_runtime(bundle): + parent = STATE / "runtime" + directory(parent) + ident = bundle["revision"] + if len(ident) != 24 or any(c not in "0123456789abcdef" for c in ident): + raise RuntimeError("Некорректная версия драйвера.") + target, stage = parent / ident, parent / (ident + ".partial") + source = trusted(SHARE / "payload.zip") + if hashlib.sha256(source.read_bytes()).hexdigest() != bundle["payload_sha256"]: + raise RuntimeError("Встроенный драйвер повреждён. Переустановите пакет.") + if stage.exists(): + trusted(stage, True) + shutil.rmtree(stage) + if not target.exists(): + directory(stage) + try: + with zipfile.ZipFile(source) as archive: + for name, data in entries(archive, bundle): + path = stage / name + path.parent.mkdir(mode=0o755, parents=True, exist_ok=True) + path.write_bytes(data) + path.chmod(0o644) + stage.rename(target) + finally: + if stage.exists(): + shutil.rmtree(stage) + trusted(target, True) + for name, info in bundle["files"].items(): + path = target / name + for parent in path.parents: + if parent == target.parent: + break + trusted(parent, True) + data = trusted(path).read_bytes() + if len(data) != info["bytes"] or hashlib.sha256(data).hexdigest() != info["sha256"]: + raise RuntimeError("Установленный драйвер изменён. Нужна переустановка пакета.") + return ident + + +def prepare(): + directory(STATE) + lock = STATE / "prepare.lock" + with lock.open("a") as handle: + trusted(lock) + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + with lifecycle_lock(): + return prepare_locked() + + +@contextmanager +def lifecycle_lock(): + directory(STATE) + path = STATE / "lifecycle.lock" + descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o640) + with os.fdopen(descriptor, "r+b") as handle: + trusted(path) + os.fchown(handle.fileno(), 0, grp.getgrnam("mission-core-node").gr_gid) + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + yield + + +def prepare_locked(): + bundle = manifest() + report = { + "schema": "missioncore.node.device-preparation/v1", + "model_id": "insta360.x4", + "revision": bundle["revision"], + "run_id": str(uuid.uuid4()), + "started_at": time.time(), + "monotonic_started": time.monotonic(), + "state": "running", + "steps": [{"id": key, "label": label, "state": "pending"} for key, label in STEPS], + } + + def publish(): + report["updated_at"] = time.time() + write(STATE / "preparation.json", (json.dumps(report, ensure_ascii=False) + "\n").encode()) + + publish() + try: + assert_safe() + for step in report["steps"]: + step["state"] = "running" + publish() + if step["id"] == "platform": + release = platform.freedesktop_os_release() + if ( + release.get("ID"), + release.get("VERSION_ID"), + platform.machine(), + sys.version_info[:2], + ) != ("ubuntu", "24.04", "x86_64", (3, 12)): + raise RuntimeError("Этот пакет поддерживает Ubuntu 24.04 amd64.") + elif step["id"] == "payload": + data = trusted(SHARE / "payload.zip").read_bytes() + if hashlib.sha256(data).hexdigest() != bundle["payload_sha256"]: + raise RuntimeError("Встроенный драйвер повреждён.") + elif step["id"] == "runtime": + ident = install_runtime(bundle) + assert_safe() + write(STATE / "active.path", (ident + "\n").encode()) + elif step["id"] == "access": + run("/usr/bin/udevadm", "control", "--reload-rules") + run( + "/usr/bin/udevadm", + "trigger", + "--action=change", + "--subsystem-match=usb", + "--attr-match=idVendor=2e1a", + "--attr-match=idProduct=0002", + "--attr-match=product=Insta360 X4", + ) + run("/usr/bin/udevadm", "settle", "--timeout=10") + elif step["id"] == "service": + maintenance = STATE / "maintenance" + if maintenance.exists(): + trusted(maintenance).unlink() + run("/usr/bin/systemctl", "daemon-reload") + for service in ( + "mission-core-insta360.service", + "mission-core-insta360-supervisor.service", + ): + run("/usr/bin/systemctl", "enable", service) + run("/usr/bin/systemctl", "start", service) + run("/usr/bin/systemctl", "is-active", "--quiet", service) + step["state"] = "complete" + publish() + report["state"] = "complete" + except ( + OSError, + ValueError, + RuntimeError, + subprocess.SubprocessError, + zipfile.BadZipFile, + ) as error: + report["state"] = "error" + message = ( + str(error) + if isinstance(error, RuntimeError) + else "Не удалось подготовить X4. Повторите действие." + ) + report["message"] = message[:300] + for step in report["steps"]: + if step["state"] == "running": + step.update(state="error", message=message[:300]) + elif step["state"] == "pending": + step["state"] = "blocked" + report["duration_seconds"] = time.monotonic() - report["monotonic_started"] + publish() + return report["state"] == "complete" + + +if __name__ == "__main__": + os.umask(0o022) + if os.geteuid() or sys.argv[1:] not in ([], ["--assert-safe"], ["--quiesce"]): + sys.exit(1) + if sys.argv[1:] == ["--quiesce"]: + with lifecycle_lock(): + assert_safe() + write(STATE / "maintenance", b"package-lifecycle\n") + elif sys.argv[1:]: + assert_safe() + else: + sys.exit(0 if prepare() else 1) diff --git a/plugins/insta360-x4/packaging/prerm b/plugins/insta360-x4/packaging/prerm new file mode 100644 index 0000000..9077918 --- /dev/null +++ b/plugins/insta360-x4/packaging/prerm @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +case "$1" in + remove|upgrade|deconfigure) + /usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py --quiesce + if [ -d /run/systemd/system ]; then + systemctl stop mission-core-insta360-supervisor.service + systemctl stop 'mission-core-x4@*.service' + systemctl stop mission-core-insta360.service + fi + ;; +esac diff --git a/plugins/insta360-x4/packaging/python-lock.json b/plugins/insta360-x4/packaging/python-lock.json new file mode 100644 index 0000000..f2cc006 --- /dev/null +++ b/plugins/insta360-x4/packaging/python-lock.json @@ -0,0 +1,137 @@ +{ + "schema": "missioncore.insta360.python-lock/v1", + "python": "3.12", + "platform": "linux-amd64", + "wheels": [ + { + "name": "aiohappyeyeballs-2.7.1-py3-none-any.whl", + "sha256": "9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", + "bytes": 15038 + }, + { + "name": "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", + "bytes": 1719929 + }, + { + "name": "aioice-0.10.2-py3-none-any.whl", + "sha256": "14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", + "bytes": 24875 + }, + { + "name": "aiortc-1.14.0-py3-none-any.whl", + "sha256": "4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e", + "bytes": 93183 + }, + { + "name": "aiosignal-1.4.0-py3-none-any.whl", + "sha256": "053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", + "bytes": 7490 + }, + { + "name": "annotated_types-0.8.0-py3-none-any.whl", + "sha256": "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", + "bytes": 13427 + }, + { + "name": "attrs-26.1.0-py3-none-any.whl", + "sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", + "bytes": 67548 + }, + { + "name": "av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", + "sha256": "7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", + "bytes": 41174337 + }, + { + "name": "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", + "bytes": 221822 + }, + { + "name": "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", + "bytes": 4712478 + }, + { + "name": "dnspython-2.8.0-py3-none-any.whl", + "sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", + "bytes": 331094 + }, + { + "name": "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", + "sha256": "494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", + "bytes": 242411 + }, + { + "name": "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", + "sha256": "14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", + "bytes": 33364 + }, + { + "name": "idna-3.19-py3-none-any.whl", + "sha256": "815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", + "bytes": 68550 + }, + { + "name": "ifaddr-0.2.0-py3-none-any.whl", + "sha256": "085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", + "bytes": 12314 + }, + { + "name": "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", + "bytes": 256322 + }, + { + "name": "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", + "bytes": 61639 + }, + { + "name": "pycparser-3.0-py3-none-any.whl", + "sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", + "bytes": 48172 + }, + { + "name": "pydantic-2.11.7-py3-none-any.whl", + "sha256": "dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", + "bytes": 444782 + }, + { + "name": "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", + "bytes": 2002028 + }, + { + "name": "pyee-14.0.0-py3-none-any.whl", + "sha256": "3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d", + "bytes": 15553 + }, + { + "name": "pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", + "bytes": 2434534 + }, + { + "name": "pyopenssl-26.4.0-py3-none-any.whl", + "sha256": "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", + "bytes": 56026 + }, + { + "name": "typing_extensions-4.16.0-py3-none-any.whl", + "sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", + "bytes": 45571 + }, + { + "name": "typing_inspection-0.4.4-py3-none-any.whl", + "sha256": "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", + "bytes": 14750 + }, + { + "name": "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", + "bytes": 109835 + } + ] +} diff --git a/plugins/insta360-x4/packaging/sdk-lock.json b/plugins/insta360-x4/packaging/sdk-lock.json new file mode 100644 index 0000000..c5974b0 --- /dev/null +++ b/plugins/insta360-x4/packaging/sdk-lock.json @@ -0,0 +1,114 @@ +{ + "schema": "missioncore.insta360.sdk-source/v1", + "sdk_version": "2.1.8", + "repository": "https://github.com/pdxmusic/insta360sdk", + "commit": "3db9641ba612c639db10591d1402231662a1eb5d", + "directory": "CameraSDK-2.1.8-20260828_171805-linux-x86_64", + "source_kind": "public-third-party-mirror", + "vendor_authenticity": "not-independently-confirmed", + "redistribution_terms": "not-present-in-inspected-sdk-files", + "files": [ + { + "path": "bin/jsons/camera_conf_Insta360_ONE_X2.json", + "bytes": 10160, + "sha256": "058a31c1cc15539b4466452caa6f653c8ad9255fd4a7681d2877fe87463c7260", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_One2.json", + "bytes": 8952, + "sha256": "76b0c9b9c331237ce69129c91629345ff91c5658e7c06cf3842cbf72556dab43", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_OneR.json", + "bytes": 12177, + "sha256": "0b74225f146aeda161a68eddae63e83bc9012989f533d935166e22044474095a", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_OneRS_283.json", + "bytes": 12297, + "sha256": "7260649cb20caf3f42ed604220f4c0209db41a4ecbf7cd82350d38cbc2c1fd47", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_OneRS_577.json", + "bytes": 12177, + "sha256": "0b74225f146aeda161a68eddae63e83bc9012989f533d935166e22044474095a", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X3.json", + "bytes": 14391, + "sha256": "cf756d9155e3d4829e66f34e81d71731913366a4f0aa3677323c9663f112039c", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X4.json", + "bytes": 145665, + "sha256": "b88b647847096dcb012f21255e3dfeaff15dc6f701f257c89c484337ecdb2104", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X4_Air.json", + "bytes": 115749, + "sha256": "da0f7048c3125e1e1e11b20e641f04db4b65e9a23b51558a9ea85f99cff4097b", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X5.json", + "bytes": 152752, + "sha256": "1a7a016439fca88928ad86844e7b4902b3153d9f2553719eb4a81aed98d314c7", + "lfs": false + }, + { + "path": "bin/jsons/camera_conf_Insta360_X6.json", + "bytes": 237283, + "sha256": "9325937d5c4a60555f4c556c2e0f69ac0b89012232e4a6022f50c29bbd308aa0", + "lfs": false + }, + { + "path": "include/camera/camera.h", + "bytes": 19842, + "sha256": "1e39bb9923d9ad87173bcb566936b7a84858d63f585a6b7d00046af4225498cf", + "lfs": false + }, + { + "path": "include/camera/device_discovery.h", + "bytes": 1080, + "sha256": "55a03fa3597b2f913b112a4860ac7311f0620d7d01b1a75c6150779178d8b97c", + "lfs": false + }, + { + "path": "include/camera/ins_types.h", + "bytes": 5960, + "sha256": "df13cb17a4972aec2cb05aa01383eb1c0d58d47d9379b9fc53940da581a98eac", + "lfs": false + }, + { + "path": "include/camera/photography_settings.h", + "bytes": 20377, + "sha256": "f19047dde6a7903b9de8099685a92200caba3b279e9308f7563990e3db0b19b5", + "lfs": false + }, + { + "path": "include/stream/stream_delegate.h", + "bytes": 1425, + "sha256": "115f757427a1c8542bcc912aeb10550615a8c538f7c37d0813fa9565d524b465", + "lfs": false + }, + { + "path": "include/stream/stream_types.h", + "bytes": 436, + "sha256": "2791062f6e45603b47f9b26390506c2b49965efcafa7f5748cb71e89a49baaf9", + "lfs": false + }, + { + "path": "lib/libCameraSDK.so", + "bytes": 17031504, + "sha256": "6d20aca1930101293308c056cef552c0beb8cbf1d6c7d567a79e95a52f9b1373", + "lfs": true + } + ] +} diff --git a/plugins/insta360-x4/packaging/supervisor.py b/plugins/insta360-x4/packaging/supervisor.py new file mode 100644 index 0000000..a6947dc --- /dev/null +++ b/plugins/insta360-x4/packaging/supervisor.py @@ -0,0 +1,157 @@ +"""Root-only USB-to-unit reconciler. No client commands, SDK loading or network.""" + +import json +import os +import re +import signal +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from layout import CONTROL, active, directory, trusted, write # noqa: E402 +from runtime.identity import read_binding # noqa: E402 + +UNITS = Path("/run/systemd/system") +PREFIX = "mission-core-x4@" +PATTERN = re.compile(r"instax4_[0-9a-f]{32}") + + +def systemctl(*args): + subprocess.run( + ["/usr/bin/systemctl", *args], + check=True, + timeout=20, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + +def unit(binding): + runtime = trusted(active(), True) + # Every interpolated value is a verified OS binding or root-owned revision. + return f"""[Unit] +Description=Mission Core isolated Insta360 X4 +After=mission-core-insta360-supervisor.service +[Service] +Type=exec +DynamicUser=yes +User=mcx4-{binding.device_id[-20:]} +Group=mission-core-node +SupplementaryGroups=mission-core-x4-usb +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/bootstrap.py worker {binding.port} +WorkingDirectory={runtime}/bin +StateDirectory=mission-core-x4/{binding.device_id} +StateDirectoryMode=0700 +RuntimeDirectory=mission-core-x4-instances/{binding.device_id} +RuntimeDirectoryMode=0750 +UMask=0007 +PrivateDevices=yes +BindPaths={binding.device_path} +DevicePolicy=closed +DeviceAllow={binding.device_path} rw +PrivateNetwork=yes +NoNewPrivileges=yes +CapabilityBoundingSet= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +LockPersonality=yes +SystemCallFilter=~@mount +ReadOnlyPaths={CONTROL} +TasksMax=64 +MemoryMax=384M +CPUQuota=150% +LimitNOFILE=256 +LimitCORE=0 +TimeoutStopSec=8 +KillMode=control-group +Restart=no +StandardOutput=null +StandardError=null +""".encode() + + +def bindings(): + found, duplicates = {}, set() + for path in Path("/sys/bus/usb/devices").iterdir(): + try: + value = read_binding(path.name) + except (OSError, ValueError): + continue + if value.device_id in found: + duplicates.add(value.device_id) + found[value.device_id] = value + return {ident: value for ident, value in found.items() if ident not in duplicates} + + +def main(): + if os.geteuid() or len(sys.argv) != 1: + raise RuntimeError("Use the fixed installed supervisor unit") + directory(CONTROL) + # Captured by root; a worker need not gain ptrace access to PID 1 merely + # to prove it uses a different network namespace. + write(CONTROL / "host-net-inode", str(Path("/proc/self/ns/net").stat().st_ino).encode()) + stopping = False + + def stop(*_): + nonlocal stopping + stopping = True + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + known = {} + startup = True + record = CONTROL / "bindings.json" + if record.exists(): + previous = json.loads(trusted(record).read_text()) + for ident, info in previous.items(): + if not PATTERN.fullmatch(ident): + raise RuntimeError("Invalid previous camera binding") + known[ident] = info + while not stopping: + observed = bindings() + current = { + ident: { + "port": item.port, + "bus": item.bus, + "address": item.address, + "revision": active().name, + } + for ident, item in observed.items() + } + changed = False + for ident in list(known): + if current.get(ident) != known[ident]: + systemctl("stop", PREFIX + ident + ".service") + path = UNITS / (PREFIX + ident + ".service") + if path.exists(): + trusted(path).unlink() + del known[ident] + changed = True + additions = [ident for ident in current if ident not in known] + for ident in additions: + item = observed[ident] + if read_binding(item.port) != item: + continue + write(UNITS / (PREFIX + ident + ".service"), unit(item)) + known[ident] = current[ident] + changed = True + if changed: + write(record, json.dumps(known, sort_keys=True).encode()) + systemctl("daemon-reload") + for ident in list(current) if startup else additions: + if ident in known: + systemctl("start", PREFIX + ident + ".service") + startup = False + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/plugins/insta360-x4/runtime/__init__.py b/plugins/insta360-x4/runtime/__init__.py new file mode 100644 index 0000000..aba7980 --- /dev/null +++ b/plugins/insta360-x4/runtime/__init__.py @@ -0,0 +1 @@ +"""Mission Core's private Insta360 X4 worker implementation.""" diff --git a/plugins/insta360-x4/runtime/broker.py b/plugins/insta360-x4/runtime/broker.py new file mode 100644 index 0000000..52a5165 --- /dev/null +++ b/plugins/insta360-x4/runtime/broker.py @@ -0,0 +1,270 @@ +"""Unprivileged model broker; public authority stays in Node/Core.""" + +import pwd +import re +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +from .errors import failure +from .http import Server, request +from .operations import UNKNOWN, Operations + +ROOT = Path("/run/mission-core-insta360") +INSTANCES = Path("/run/mission-core-x4-instances") +IDENTIFIER = re.compile(r"instax4_[0-9a-f]{32}") +STATE = Path("/var/lib/mission-core-insta360-broker") + + +def worker_socket(identifier): + if not isinstance(identifier, str) or not IDENTIFIER.fullmatch(identifier): + raise ValueError("Invalid camera identity") + return INSTANCES / identifier / "driver.sock" + + +class Broker: + def __init__(self): + self.instance = "x4broker_" + uuid.uuid4().hex + self.media_lock = threading.Lock() + self.media = None + self.media_operations = {} + self.capture_locks = {} + + def capture_lock(self, identifier): + with self.media_lock: + if identifier not in self.capture_locks and len(self.capture_locks) >= 500: + raise RuntimeError("Camera inventory exceeds the protocol limit") + return self.capture_locks.setdefault(identifier, threading.Lock()) + + def capture_operation(self, identifier, value): + path = worker_socket(identifier) + lock = self.capture_lock(identifier) + with self.media_lock: + if self.media is None: + from .media import Engine + + self.media = Engine() + # Only one camera's START/STOP is serialized here; other devices keep + # independent locks and SDK operation journals. + with lock: + current = request(path, "/snapshot", timeout=2) + session = current["session_id"] + if current.get("id") != identifier or value.get("session") != { + "device_id": identifier, + "session_id": session, + }: + raise ValueError("Camera session changed") + key = (identifier, session) + starting = value["action_id"] == "preview.start" + starting_idle = starting and current.get("status", {}).get("preview") != 1 + if starting: + if not current.get("online"): + raise RuntimeError("Camera state is unavailable") + self.media.capture(key, path, True) + try: + result = request(path, "/operation", value, timeout=55) + except (OSError, RuntimeError, ValueError): + if starting_idle: + self.media.capture(key, path, False) + raise + if (starting_idle and result.get("state") != "complete") or ( + not starting and result.get("state") == "complete" + ): + # Releasing a decoder never sends STOP to the camera or SD. + self.media.capture(key, path, False) + return result + + def verify_operation(self, identifier, value): + path = worker_socket(identifier) + # Serialize with preview transitions, preserving one receipt namespace + # whether verification uses the live decoder or an idle camera check. + with self.capture_lock(identifier): + current = request(path, "/snapshot", timeout=2) + if current.get("id") != identifier: + raise ValueError("Camera identity changed") + operation = Operations( + identifier, + current["session_id"], + STATE / identifier / "verification", + VerificationCalls(self, path, value), + ) + return operation.execute(value) + + def media_operation(self, identifier, value): + path = worker_socket(identifier) + current = request(path, "/snapshot", timeout=2) + if current.get("id") != identifier: + raise ValueError("Camera identity changed") + session = current["session_id"] + with self.media_lock: + if self.media is None: + from .media import Engine + + self.media = Engine() + previous = self.media_operations.get(identifier) + if previous is None or previous.session_id != session: + previous = Operations( + identifier, + session, + STATE / identifier, + MediaCalls(self.media, (identifier, session), path), + ) + self.media_operations[identifier] = previous + return previous.execute(value) + + def snapshots(self): + paths = sorted(INSTANCES.glob("instax4_*/driver.sock")) + if len(paths) > 500: + raise RuntimeError("Camera inventory exceeds the protocol limit") + + def read(path): + if not IDENTIFIER.fullmatch(path.parent.name): + return None + try: + value = request(path, "/snapshot", timeout=0.75) + return value if value.get("id") == path.parent.name else None + except (OSError, RuntimeError, ValueError): + return None + + pool = ThreadPoolExecutor(max_workers=8) + pending = [pool.submit(read, path) for path in paths] + values = [] + try: + for future in as_completed(pending, timeout=2): + value = future.result() + if value is not None: + values.append(value) + except TimeoutError: + pass + finally: + pool.shutdown(wait=False, cancel_futures=True) + return sorted(values, key=lambda value: value["id"]) + + def item(self, value, node): + status = value["status"] + preview = status.get("preview") + return { + "id": value["id"], + "name": "Insta360 X4", + "model": "Insta360 X4", + "kind": "insta360.x4", + "initializable": True, + "configured": False, + "prepared": value["prepared"], + "verified": value["verified"], + "online": value["online"], + "preparation_safe": value["preparation_safe"], + "usb": "USB", + "firmware": status.get("firmware"), + "layers": [], + "camera_status": status, + "snapshot": { + "revision": value["revision"], + "observed_at": value["observed_at"], + "context": { + "session_id": value["session_id"], + "opened_at": value["opened_at"], + "device": { + "device_id": value["id"], + "stability": "stable", + "basis": "hardware-identifier", + "model": { + "plugin_id": "missioncore.insta360", + "plugin_version": "0.1.3", + "model_id": "insta360.x4", + }, + }, + "execution": { + "node_id": node, + "agent_instance_id": self.instance, + "platform": "linux", + }, + }, + "acquisition": "streaming" + if preview == 1 + else "idle" + if preview == 0 + else "failed", + "connectivity": "connected" if value["online"] else "offline", + "enrollment": "enrolled" if value["prepared"] else "empty", + "message": value["message"], + }, + } + + def dispatch(self, method, route, value, headers): + if method == "GET" and route == "/prepare-safe": + snapshots = self.snapshots() + expected = list(INSTANCES.glob("instax4_*")) + return { + "safe": len(snapshots) == len(expected) + and all(item["preparation_safe"] for item in snapshots) + } + node = headers.get("X-Node-Id", "") + if not re.fullmatch(r"node_[0-9a-f]{64}", node): + raise ValueError("Node identity is required") + if method == "GET" and route == "/inventory": + return {"items": [self.item(item, node) for item in self.snapshots()]} + if method == "POST" and route == "/operation": + identifier = value["session"]["device_id"] + if value.get("action_id") in ("preview.start", "preview.stop"): + return self.capture_operation(identifier, value) + if value.get("action_id") in ("offer", "close-peer"): + return self.media_operation(identifier, value) + if value.get("action_id") == "verify": + return self.verify_operation(identifier, value) + return request(worker_socket(identifier), "/operation", value, timeout=55) + raise ValueError("Unsupported model route") + + +class MediaCalls: + def __init__(self, engine, key, path): + self.engine, self.key, self.path = engine, key, path + + def call(self, action, params): + if action == "offer": + current = request(self.path, "/snapshot", timeout=2) + if ( + current.get("session_id") != self.key[1] + or not current.get("online") + or current.get("status", {}).get("preview") != 1 + ): + raise RuntimeError("Camera preview is not active") + return self.engine.call(self.key, self.path, action, params) + + +class VerificationCalls: + def __init__(self, broker, path, command): + self.broker, self.path, self.command = broker, path, command + + def call(self, action, params): + current = request(self.path, "/snapshot", timeout=2) + session = self.command["session"] + if ( + current.get("id") != session["device_id"] + or current.get("session_id") != session["session_id"] + or not current.get("online") + ): + return dict(UNKNOWN) + status = current.get("status", {}) + if status.get("recording") != 0: + return failure("verification_recording_active") + if status.get("preview") == 1: + engine = self.broker.media + if engine is None: + return failure("preview_restart_required") + return engine.verify((session["device_id"], session["session_id"])) + if status.get("preview") == 0: + # Preserve worker ownership of the bounded temporary idle preview. + return request(self.path, "/operation", self.command, timeout=55) + return dict(UNKNOWN) + + +def main(): + path = ROOT / "driver.sock" + if path.exists(): + path.unlink() + allowed = {0, pwd.getpwnam("mission-core-node").pw_uid} + with Server(path, Broker().dispatch, allowed) as server: + path.chmod(0o660) + server.serve_forever(poll_interval=0.5) diff --git a/plugins/insta360-x4/runtime/errors.py b/plugins/insta360-x4/runtime/errors.py new file mode 100644 index 0000000..6776924 --- /dev/null +++ b/plugins/insta360-x4/runtime/errors.py @@ -0,0 +1,12 @@ +"""Public messages are allowlisted; vendor error text never crosses the boundary.""" + +MESSAGES = { + "unsupported_camera_parameter": "Параметр или действие недоступны в текущем режиме камеры.", + "verification_recording_active": "Для проверки изображения остановите запись на камере.", + "preview_no_decodable_image": "Камера не передала декодируемое изображение.", + "preview_restart_required": "Не поступают свежие кадры. Остановите и снова начните просмотр.", +} + + +def failure(code): + return {"state": "error", "error": code} diff --git a/plugins/insta360-x4/runtime/frames.py b/plugins/insta360-x4/runtime/frames.py new file mode 100644 index 0000000..4b6b85b --- /dev/null +++ b/plugins/insta360-x4/runtime/frames.py @@ -0,0 +1,191 @@ +"""Bounded per-camera encoded fanout. Readers never consume each other's data.""" + +import json +import re +import struct +import threading +import time +from collections import deque + +MAX_PACKET = 4 * 1024 * 1024 +MAX_BYTES = 8 * 1024 * 1024 +MAX_ENTRIES = 64 +MAX_WIRE = MAX_PACKET + 1028 +MAX_PARAMETERS = 65536 +START_CODE = re.compile(b"\x00\x00(?:\x00)?\x01") + + +class Parameters: + """Retain only codec setup, so a late viewer can decode the next keyframe. + + Parameter sets describe this preview generation, never a previous session. + Media history and camera commands are not retained or replayed. + """ + + def __init__(self): + self.streams = {} + + def packet(self, header, data): + codec = header["codec"] + required = (7, 8) if codec == 0 else (32, 33, 34) + keyframes = (5,) if codec == 0 else (16, 17, 18, 19, 20, 21) + saved = self.streams.setdefault((header["stream_index"], codec), {}) + markers = START_CODE.finditer(data) + marker = next(markers, None) + present, keyframe = set(), False + while marker is not None: + following = next(markers, None) + if marker.end() >= len(data): + break + kind = data[marker.end()] & 31 if codec == 0 else (data[marker.end()] >> 1) & 63 + keyframe |= kind in keyframes + if kind in required: + end = following.start() if following is not None else len(data) + value = data[marker.start() : end] + if len(value) > MAX_PARAMETERS: + saved.clear() + return data + saved[kind] = value + present.add(kind) + marker = following + if keyframe and all(kind in saved for kind in required): + prefix = b"".join(saved[kind] for kind in required if kind not in present) + if len(prefix) + len(data) <= MAX_PACKET: + return prefix + data + return data + + +def encode(value): + if value is None: + return b"" + header, data = value + raw = json.dumps(header, allow_nan=False, separators=(",", ":")).encode() + if len(raw) > 1024 or not 0 < len(data) <= MAX_PACKET: + raise ValueError("Invalid video envelope") + return struct.pack("!I", len(raw)) + raw + data + + +def decode(raw): + if not raw: + return None + if len(raw) < 5 or len(raw) > MAX_WIRE: + raise ValueError("Invalid video envelope") + size = struct.unpack("!I", raw[:4])[0] + if not 0 < size <= 1024 or len(raw) <= 4 + size: + raise ValueError("Invalid video envelope") + header, data = json.loads(raw[4 : 4 + size]), raw[4 + size :] + if ( + not isinstance(header, dict) + or any( + type(header.get(key)) is not int or header[key] < 0 + for key in ("cursor", "generation", "stream_index", "codec") + ) + or header["stream_index"] not in (0, 1) + or header["codec"] not in (0, 1) + or type(header.get("gap")) is not bool + or not 0 < len(data) <= MAX_PACKET + ): + raise ValueError("Invalid video metadata") + return header, data + + +class Feed: + def __init__(self, camera): + self.camera = camera + self.lock = threading.Condition() + self.source_lock = threading.Lock() + self.queue = deque() + self.bytes = self.cursor = self.generation = 0 + self.source_generation = None + self.parameters = Parameters() + self.closed = threading.Event() + self.thread = threading.Thread(target=self.pump, daemon=True) + + def clear(self): + with self.lock: + self.queue.clear() + self.bytes = 0 + self.parameters = Parameters() + self.generation += 1 + self.lock.notify_all() + + def append(self, value): + source, data = value + if not 0 < len(data) <= MAX_PACKET: + self.clear() + return + with self.lock: + if self.source_generation != source["generation"]: + self.queue.clear() + self.bytes = 0 + self.source_generation = source["generation"] + self.generation += 1 + self.parameters = Parameters() + data = self.parameters.packet(source, data) + while self.queue and ( + self.bytes + len(data) > MAX_BYTES or len(self.queue) >= MAX_ENTRIES + ): + _, removed, _ = self.queue.popleft() + self.bytes -= len(removed) + self.cursor += 1 + header = dict(source, cursor=self.cursor, generation=self.generation) + self.queue.append((header, data, time.monotonic())) + self.bytes += len(data) + self.lock.notify_all() + + def read(self, cursor, wait=0.1): + if type(cursor) is not int or not 0 <= cursor < 2**64: + raise ValueError("Invalid video cursor") + deadline = time.monotonic() + min(max(wait, 0), 0.2) + with self.lock: + while True: + for header, data, observed in self.queue: + if header["cursor"] > cursor and time.monotonic() - observed < 2: + return dict( + header, gap=bool(cursor and header["cursor"] != cursor + 1) + ), data + remaining = deadline - time.monotonic() + if remaining <= 0 or self.closed.is_set(): + return None + self.lock.wait(remaining) + + def reader(self): + with self.lock: + cursor = self.cursor + + def read(): + nonlocal cursor + value = self.read(cursor, wait=0) + if value is not None: + cursor = value[0]["cursor"] + return value + + return read + + def change(self, action): + # No packet read from the previous preview may be appended after a + # START/STOP boundary and mistaken for evidence of the new preview. + with self.source_lock: + self.clear() + try: + return action() + finally: + self.clear() + + def pump(self): + while not self.closed.is_set(): + try: + with self.source_lock: + value = self.camera.video() + if value is not None: + self.append(value) + continue + except (OSError, RuntimeError, ValueError): + self.clear() + self.closed.wait(0.01) + + def close(self): + self.closed.set() + self.clear() + if self.thread.is_alive(): + self.thread.join(timeout=1) diff --git a/plugins/insta360-x4/runtime/http.py b/plugins/insta360-x4/runtime/http.py new file mode 100644 index 0000000..e34cb30 --- /dev/null +++ b/plugins/insta360-x4/runtime/http.py @@ -0,0 +1,145 @@ +"""Bounded private HTTP over Unix sockets, never a public device endpoint.""" + +import http.client +import json +import socket +import socketserver +import struct +import threading +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler + +LIMIT = 65536 +INVENTORY_LIMIT = 2 * 1024 * 1024 + + +@dataclass(frozen=True) +class Binary: + data: bytes + + +def request(path, route, value=None, timeout=25, binary=False): + client = http.client.HTTPConnection("driver", timeout=timeout) + client.sock = socket.socket(socket.AF_UNIX) + client.sock.settimeout(timeout) + try: + client.sock.connect(str(path)) + body = None if value is None else json.dumps(value, allow_nan=False).encode() + client.request( + "GET" if value is None else "POST", route, body, {"Content-Type": "application/json"} + ) + reply = client.getresponse() + limit = ( + 4 * 1024 * 1024 + 1028 + if binary + else INVENTORY_LIMIT + if route == "/inventory" + else LIMIT + ) + raw = reply.read(limit + 1) + if len(raw) > limit or reply.status != 200: + raise RuntimeError("Camera service request failed") + if binary: + if reply.getheader("Content-Type") != "application/octet-stream": + raise RuntimeError("Invalid video response") + return raw + return json.loads(raw) + except http.client.HTTPException as error: + raise RuntimeError("Camera service response is incomplete") from error + finally: + client.close() + + +class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): + daemon_threads = True + block_on_close = False + + def __init__(self, path, dispatch, allowed_uids): + self.dispatch = dispatch + self.allowed_uids = frozenset(allowed_uids) + self.slots = threading.BoundedSemaphore(12) + super().__init__(str(path), Handler) + + def verify_request(self, request, address): + _, uid, _ = struct.unpack( + "3i", request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12) + ) + return uid in self.allowed_uids + + def process_request(self, request, address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, address) + except BaseException: + self.slots.release() + raise + + def process_request_thread(self, request, address): + try: + super().process_request_thread(request, address) + finally: + self.slots.release() + + +class Handler(BaseHTTPRequestHandler): + def setup(self): + self.request.settimeout(10) + super().setup() + + def log_message(self, *_): + pass + + def do_GET(self): + self.handle_request() + + def do_POST(self): + self.handle_request() + + def handle_request(self): + try: + if self.headers.get("Transfer-Encoding"): + raise ValueError("Chunked commands are not supported") + value = None + if self.command == "POST": + length = int(self.headers.get("Content-Length", "0")) + if ( + not 0 < length <= LIMIT + or self.headers.get("Content-Type") != "application/json" + ): + raise ValueError("Invalid command size") + raw = self.rfile.read(length) + if len(raw) != length: + raise ValueError("Truncated command") + value = json.loads(raw) + result = self.server.dispatch(self.command, self.path, value, self.headers) + status = 200 + except (ValueError, KeyError, TypeError): + status, result = 400, {"error": "Некорректная команда камеры."} + except (RuntimeError, OSError, TimeoutError): + status, result = 409, {"error": "Камера не ответила. Проверьте подключение."} + binary = isinstance(result, Binary) + data = ( + result.data + if binary + else json.dumps(result, ensure_ascii=False, allow_nan=False).encode() + ) + limit = ( + 4 * 1024 * 1024 + 1028 + if binary + else INVENTORY_LIMIT + if self.path == "/inventory" + else LIMIT + ) + if len(data) > limit: + binary = False + status, data = 500, b'{"error":"Camera response exceeds its limit"}' + self.send_response(status) + self.send_header( + "Content-Type", "application/octet-stream" if binary else "application/json" + ) + self.send_header("Content-Length", str(len(data))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(data) diff --git a/plugins/insta360-x4/runtime/identity.py b/plugins/insta360-x4/runtime/identity.py new file mode 100644 index 0000000..3987478 --- /dev/null +++ b/plugins/insta360-x4/runtime/identity.py @@ -0,0 +1,85 @@ +"""Read-only USB admission, before vendor enumeration or library loading.""" + +import hashlib +import os +import re +import stat +from dataclasses import dataclass +from pathlib import Path + + +def device_id(serial): + return "instax4_" + hashlib.sha256(serial.encode()).hexdigest()[:32] + + +@dataclass(frozen=True) +class Binding: + device_id: str + port: str + bus: int + address: int + serial: str + + @property + def device_path(self): + return f"/dev/bus/usb/{self.bus:03d}/{self.address:03d}" + + +def read_binding(port, root=Path("/sys/bus/usb/devices")): + if not isinstance(port, str) or not re.fullmatch( + r"[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*", port + ): + raise ValueError("Invalid camera transport binding") + path = root / port + + def read(name): + return (path / name).read_text().strip() + + if (read("idVendor"), read("idProduct"), read("product")) != ("2e1a", "0002", "Insta360 X4"): + raise ValueError("USB device is not the admitted X4 model") + serial = read("serial") + if not serial or len(serial) > 256 or any(ord(c) < 32 for c in serial): + raise ValueError("Camera identity is unavailable") + bus, address = int(read("busnum")), int(read("devnum")) + if not 1 <= bus <= 999 or not 1 <= address <= 127: + raise ValueError("Invalid USB bus address") + return Binding(device_id(serial), port, bus, address, serial) + + +def verify_isolation(binding): + """No override switch. An ordinary host process cannot load the SDK here. + + The root-owned instance service must have PrivateDevices+one BindPaths, + DevicePolicy=closed, NoNewPrivileges, no capabilities, and PrivateNetwork. + Service declarations additionally enforce cgroup access to this USB node. + """ + if os.uname().sysname != "Linux" or os.geteuid() == 0: + raise RuntimeError("X4 requires an unprivileged isolated Linux worker") + current = read_binding(binding.port) + if current != binding: + raise RuntimeError("Camera changed before SDK initialization") + status = dict( + line.split(":", 1) + for line in Path("/proc/self/status").read_text().splitlines() + if ":" in line + ) + if int(status["CapEff"].strip(), 16) or status["NoNewPrivs"].strip() != "1": + raise RuntimeError("Camera process has unexpected privileges") + proof = Path("/run/mission-core-x4-control/host-net-inode") + for path in (proof.parent, proof): + info = path.lstat() + if path.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError("Untrusted host namespace evidence") + host_network = int(proof.read_text()) + if host_network <= 0 or Path("/proc/self/ns/net").stat().st_ino == host_network: + raise RuntimeError("Camera process has access to the host network") + devices = list(Path("/dev/bus/usb").glob("*/*")) + if devices != [Path(binding.device_path)]: + raise RuntimeError("Camera process can see unrelated USB devices") + info = devices[0].lstat() + expected_minor = (binding.bus - 1) * 128 + binding.address - 1 + if not stat.S_ISCHR(info.st_mode) or (os.major(info.st_rdev), os.minor(info.st_rdev)) != ( + 189, + expected_minor, + ): + raise RuntimeError("Camera device node does not match the transport") diff --git a/plugins/insta360-x4/runtime/lifecycle.py b/plugins/insta360-x4/runtime/lifecycle.py new file mode 100644 index 0000000..6e0d6a7 --- /dev/null +++ b/plugins/insta360-x4/runtime/lifecycle.py @@ -0,0 +1,24 @@ +"""OS lock shared by every SDK effect and exclusive profile activation.""" + +import fcntl +from contextlib import contextmanager +from pathlib import Path + +ROOT = Path("/var/lib/mission-core-insta360") + + +@contextmanager +def acquisition(wait=False): + path = ROOT / "lifecycle.lock" + for item in (ROOT, path): + info = item.lstat() + if item.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022: + raise RuntimeError("Untrusted camera lifecycle lock") + with path.open("rb") as handle: + try: + fcntl.flock(handle, fcntl.LOCK_SH | (0 if wait else fcntl.LOCK_NB)) + except BlockingIOError: + raise RuntimeError("Подготовка драйвера ещё выполняется.") from None + if (ROOT / "maintenance").exists(): + raise RuntimeError("Драйвер X4 обновляется. Повторите после подготовки.") + yield diff --git a/plugins/insta360-x4/runtime/media.py b/plugins/insta360-x4/runtime/media.py new file mode 100644 index 0000000..220f6df --- /dev/null +++ b/plugins/insta360-x4/runtime/media.py @@ -0,0 +1,394 @@ +"""Private WebRTC operator view outside the vendor SDK network namespace. + +Opening/closing a peer never sends a camera command. Each source has its own +worker socket, session, decoder and bounded latest-frame slot. No stitching. +""" + +import asyncio +import ipaddress +import json +import logging +import threading +import time +import uuid +from concurrent.futures import TimeoutError as FutureTimeout +from fractions import Fraction + +import aioice.ice +import av +from aiortc import ( + RTCConfiguration, + RTCPeerConnection, + RTCRtpSender, + RTCSessionDescription, + VideoStreamTrack, +) +from aiortc.mediastreams import MediaStreamError + +from .errors import failure +from .frames import decode +from .http import request + +NETWORKS = tuple( + ipaddress.ip_network(value) + for value in ( + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + ) +) + + +def private(value): + try: + address = ipaddress.ip_address(value) + return address.version == 4 and any(address in network for network in NETWORKS) + except ValueError: + return False + + +_host_addresses = aioice.ice.get_host_addresses +aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [ + value for value in _host_addresses(use_ipv4=True, use_ipv6=False) if private(value) +] + + +def admit_sdp(sdp): + if not isinstance(sdp, str) or not 0 < len(sdp) <= 32768: + raise ValueError("Invalid camera offer") + for line in sdp.splitlines(): + if line.startswith("a=candidate:"): + fields = line.split() + if len(fields) < 8 or not (private(fields[4]) or fields[4].endswith(".local")): + raise ValueError("Camera preview requires a private network") + + +class Source: + def __init__(self, path, session): + self.path, self.session = path, session + self.lock = threading.Lock() + self.frame = None + self.sequence = 0 + self.observed = 0.0 + self.started = time.monotonic() + self.closed = threading.Event() + self.thread = threading.Thread(target=self.pump, daemon=True) + + def current(self): + with self.lock: + return self.sequence, self.frame, self.observed + + def pump(self): + cursor, key, decoder, resized_at = 0, None, None, 0.0 + try: + while not self.closed.is_set(): + raw = request( + self.path, + "/video", + {"session_id": self.session, "cursor": cursor}, + timeout=2, + binary=True, + ) + value = decode(raw) + if value is None: + continue + header, data = value + cursor = header["cursor"] + if header["stream_index"] != 0: + continue + current = (header["generation"], header["codec"]) + if decoder is None or key != current or header["gap"]: + decoder = av.CodecContext.create( + "h264" if header["codec"] == 0 else "hevc", "r" + ) + decoder.thread_count = 1 + key = current + try: + for packet in decoder.parse(data): + for frame in decoder.decode(packet): + if not (0 < frame.width <= 4096 and 0 < frame.height <= 2160): + raise RuntimeError("Camera frame exceeds the admitted view profile") + now = time.monotonic() + if now - resized_at < 1 / 15: + continue + width = min(1280, frame.width) // 2 * 2 + height = max(2, round(frame.height * width / frame.width) // 2 * 2) + output = frame.reformat(width=width, height=height, format="yuv420p") + output.pts = round((now - self.started) * 90000) + output.time_base = Fraction(1, 90000) + with self.lock: + self.frame, self.observed = output, now + self.sequence += 1 + resized_at = now + except av.error.FFmpegError: + decoder = None + except (OSError, ValueError, RuntimeError) as error: + logging.getLogger(__name__).warning( + "X4 preview source stopped (%s)", type(error).__name__ + ) + finally: + self.closed.set() + with self.lock: + self.frame = None + + def close(self): + self.closed.set() + + +class Track(VideoStreamTrack): + def __init__(self, source): + super().__init__() + self.source, self.sequence, self.next_at = source, 0, 0.0 + + async def recv(self): + while self.readyState == "live" and not self.source.closed.is_set(): + await asyncio.sleep(max(0, self.next_at - time.monotonic())) + sequence, frame, observed = self.source.current() + if frame is not None and sequence > self.sequence and time.monotonic() - observed < 2: + self.sequence = sequence + self.next_at = time.monotonic() + 1 / 15 + # Distinct AV frames per encoder: force-keyframe decisions in + # one browser must not mutate another browser's frame object. + copy = av.VideoFrame(frame.width, frame.height, "yuv420p") + for target, source in zip(copy.planes, frame.planes, strict=True): + if target.line_size == source.line_size: + target.update(source) + else: + raw = bytes(source) + padded = bytearray(target.buffer_size) + for row in range(target.height): + padded[ + row * target.line_size : row * target.line_size + target.width + ] = raw[row * source.line_size : row * source.line_size + target.width] + target.update(padded) + copy.pts, copy.time_base = frame.pts, frame.time_base + return copy + await asyncio.sleep(0.01) + raise MediaStreamError + + +class Peers: + def __init__(self, source_factory=Source): + self.items, self.sources = {}, {} + self.primed = {} + self.source_factory = source_factory + + def source(self, key, path): + source = self.sources.get(key) + if source is not None and source.closed.is_set(): + raise RuntimeError("Camera video session ended") + if source is None: + if len(self.sources) >= 4: + raise ValueError("Остановите лишний просмотр камеры.") + source = self.source_factory(path, key[1]) + self.sources[key] = source + source.thread.start() + return source + + async def prime(self, key, path): + # Start reading before the explicit camera START. Some previews expose + # an independently decodable beginning only once per capture session. + source = self.source(key, path) + if key in self.primed: + return {"ok": True} + + async def watch(): + started, active, fresh = time.monotonic(), False, time.monotonic() + while key in self.primed: + try: + status = await asyncio.to_thread(request, path, "/snapshot", timeout=2) + preview = status.get("status", {}).get("preview") + ended = status.get("session_id") != key[1] or source.closed.is_set() + if status.get("online"): + fresh = time.monotonic() + ended |= active and preview == 0 + active |= preview == 1 + ended |= active and time.monotonic() - fresh > 15 + except (OSError, RuntimeError, ValueError): + ended = True + if ended or (not active and time.monotonic() - started > 45): + await self.release(key) + return + await asyncio.sleep(1) + + self.primed[key] = asyncio.create_task(watch()) + return {"ok": True} + + async def release(self, key): + task = self.primed.pop(key, None) + if task is not None and task is not asyncio.current_task(): + task.cancel() + for identifier, entry in tuple(self.items.items()): + if entry["key"] == key: + await self.close(key, identifier) + source = self.sources.pop(key, None) + if source is not None: + source.close() + await asyncio.to_thread(source.thread.join, 3) + return {"ok": True} + + async def verify(self, key, timeout=8): + # An active preview may have emitted its only keyframe minutes ago. + # Verify two NEW decoded frames from its existing source, never open a + # competing decoder or accept a retained frame as fresh evidence. + source = self.sources.get(key) + if source is None or source.closed.is_set(): + return failure("preview_restart_required") + initial = source.current()[0] + started = time.monotonic() + while time.monotonic() - started < timeout: + if self.sources.get(key) is not source or source.closed.is_set(): + break + sequence, frame, observed = source.current() + if frame is not None and sequence >= initial + 2 and time.monotonic() - observed < 2: + return { + "state": "complete", + "result": { + "verified": True, + "streams": [ + { + "stream_index": 0, + "width": frame.width, + "height": frame.height, + "frames": sequence - initial, + } + ], + "duration_ms": round((time.monotonic() - started) * 1000), + }, + } + await asyncio.sleep(0.02) + return failure("preview_restart_required") + + async def offer(self, key, path, params): + admit_sdp(params["sdp"]) + # Inventory remains independent; these limits bound active media only. + if len(self.items) >= 4 or sum(item["key"] == key for item in self.items.values()) >= 2: + raise ValueError("Закройте лишний просмотр камеры.") + source = self.source(key, path) + pc = RTCPeerConnection(RTCConfiguration(iceServers=[])) + identifier = "peer_" + uuid.uuid4().hex + entry = {"key": key, "pc": pc, "source": source, "seen": time.monotonic(), "tasks": set()} + self.items[identifier] = entry + + async def telemetry(channel): + while identifier in self.items: + sequence, frame, observed = source.current() + if channel.readyState == "open" and channel.bufferedAmount < 65536: + channel.send( + json.dumps( + { + "frame_sequence": sequence, + "frame_age_ms": round((time.monotonic() - observed) * 1000) + if frame + else None, + "fresh": frame is not None and time.monotonic() - observed < 2, + } + ) + ) + await asyncio.sleep(0.5) + + def task(coroutine): + value = asyncio.create_task(coroutine) + entry["tasks"].add(value) + value.add_done_callback(entry["tasks"].discard) + + @pc.on("datachannel") + def datachannel(channel): + if channel.label != "sensor": + channel.close() + return + + @channel.on("message") + def message(value): + if value == "keepalive": + entry["seen"] = time.monotonic() + + task(telemetry(channel)) + + @pc.on("connectionstatechange") + async def changed(): + if pc.connectionState in ("failed", "closed"): + await self.close(key, identifier) + + async def watchdog(): + while identifier in self.items: + if source.closed.is_set() or time.monotonic() - entry["seen"] > 30: + await self.close(key, identifier) + return + await asyncio.sleep(1) + + try: + sender = pc.addTrack(Track(source)) + transceiver = next(item for item in pc.getTransceivers() if item.sender == sender) + transceiver.setCodecPreferences( + [ + codec + for codec in RTCRtpSender.getCapabilities("video").codecs + if codec.mimeType.lower() == "video/h264" + ] + ) + await pc.setRemoteDescription(RTCSessionDescription(sdp=params["sdp"], type="offer")) + await pc.setLocalDescription(await pc.createAnswer()) + task(watchdog()) + return {"peer_id": identifier, "sdp": pc.localDescription.sdp, "type": "answer"} + except BaseException: + await self.close(key, identifier) + raise + + async def close(self, key, identifier): + entry = self.items.get(identifier) + if entry is None: + return {"ok": True} + if entry["key"] != key: + raise ValueError("Preview belongs to a different camera session") + self.items.pop(identifier) + current = asyncio.current_task() + for task in tuple(entry["tasks"]): + if task is not current: + task.cancel() + await entry["pc"].close() + if key not in self.primed and not any(item["key"] == key for item in self.items.values()): + source = self.sources.pop(key) + source.close() + await asyncio.to_thread(source.thread.join, 3) + return {"ok": True} + + +class Engine: + def __init__(self): + self.loop = asyncio.new_event_loop() + self.peers = Peers() + self.thread = threading.Thread(target=self.loop.run_forever, daemon=True) + self.thread.start() + + def capture(self, key, path, start): + work = self.peers.prime(key, path) if start else self.peers.release(key) + future = asyncio.run_coroutine_threadsafe(work, self.loop) + try: + return future.result(timeout=10) + except FutureTimeout as error: + future.cancel() + raise RuntimeError("Camera decoder setup timed out") from error + + def call(self, key, path, action, params): + work = ( + self.peers.offer(key, path, params) + if action == "offer" + else self.peers.close(key, params["peer_id"]) + ) + future = asyncio.run_coroutine_threadsafe(work, self.loop) + try: + return {"state": "complete", "result": future.result(timeout=20)} + except FutureTimeout as error: + future.cancel() + raise RuntimeError("Camera preview timed out") from error + + def verify(self, key): + future = asyncio.run_coroutine_threadsafe(self.peers.verify(key), self.loop) + try: + return future.result(timeout=10) + except FutureTimeout as error: + future.cancel() + raise RuntimeError("Camera image verification timed out") from error diff --git a/plugins/insta360-x4/runtime/native.py b/plugins/insta360-x4/runtime/native.py new file mode 100644 index 0000000..fadaa49 --- /dev/null +++ b/plugins/insta360-x4/runtime/native.py @@ -0,0 +1,179 @@ +"""Typed private C ABI. Imported safely; vendor code loads only after admission.""" + +import ctypes +import json +import math +import re +import threading + +from .identity import verify_isolation + +ACTIONS = { + "details": 1, + "settings.read": 2, + "settings.apply": 3, + "preview.start": 4, + "preview.stop": 5, + "record.start": 6, + "record.stop": 7, + "photo.capture": 8, + "files.list": 9, +} +MAX_PACKET = 4 * 1024 * 1024 + + +class VideoHeader(ctypes.Structure): + _fields_ = [ + ("sequence", ctypes.c_uint64), + ("generation", ctypes.c_uint64), + ("timestamp", ctypes.c_int64), + ("stream_index", ctypes.c_int32), + ("codec", ctypes.c_int32), + ("bytes", ctypes.c_uint32), + ] + + +def parameters(action, value): + """Validate before any SDK call; parameters never select a file or library.""" + if action == "offer": + if ( + not isinstance(value, dict) + or set(value) != {"sdp", "layer"} + or value["layer"] != "preview" + or not isinstance(value["sdp"], str) + or not 0 < len(value["sdp"]) <= 32768 + ): + raise ValueError("Invalid camera preview offer") + return 0, "", 0.0 + if action == "close-peer": + if ( + not isinstance(value, dict) + or set(value) != {"peer_id"} + or not isinstance(value["peer_id"], str) + or not re.fullmatch(r"peer_[0-9a-f]{32}", value["peer_id"]) + ): + raise ValueError("Invalid camera preview identity") + return 0, "", 0.0 + if action == "verify": + if not isinstance(value, dict) or value: + raise ValueError("Image verification uses the installed fixed profile") + return 0, "", 0.0 + if action not in ACTIONS or not isinstance(value, dict): + raise ValueError("Unsupported camera command") + if action in ("settings.read", "settings.apply"): + required = {"mode"} if action == "settings.read" else {"mode", "key", "value"} + if ( + set(value) != required + or type(value["mode"]) is not int + or not 0 <= value["mode"] <= 255 + ): + raise ValueError("Invalid camera mode") + if action == "settings.read": + return value["mode"], "", 0.0 + if value["key"] not in ( + "function_mode", + "video_resolution", + "photo_size", + "white_balance", + "iso", + "exposure_mode", + ): + raise ValueError("Unsupported camera setting") + number = value["value"] + if ( + type(number) not in (int, float) + or not math.isfinite(number) + or not 0 <= number <= 65535 + ): + raise ValueError("Invalid camera setting") + return value["mode"], value["key"], float(number) + if action == "files.list": + if ( + set(value) - {"offset"} + or type(value.get("offset", 0)) is not int + or not 0 <= value.get("offset", 0) <= 100000 + ): + raise ValueError("Invalid camera file page") + return 0, "", float(value.get("offset", 0)) + if value: + raise ValueError("Unexpected camera parameters") + return 0, "", 0.0 + + +class NativeCamera: + def __init__(self, binding, library_path, log_directory): + # No ctypes.CDLL anywhere above this check: loading a .so can execute + # constructors even before the first explicit SDK function call. + verify_isolation(binding) + self.lock = threading.RLock() + self.video_lock = threading.Lock() + self.api = ctypes.CDLL(str(library_path)) + self.api.mc_x4_abi.restype = ctypes.c_int + if self.api.mc_x4_abi() != 1: + raise RuntimeError("Incompatible camera adapter") + self.api.mc_x4_open.argtypes = [ctypes.c_char_p, ctypes.c_char_p] + self.api.mc_x4_open.restype = ctypes.c_void_p + self.api.mc_x4_call.argtypes = [ + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_double, + ] + self.api.mc_x4_call.restype = ctypes.c_char_p + self.api.mc_x4_read_video.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(VideoHeader), + ctypes.POINTER(ctypes.c_uint8), + ctypes.c_size_t, + ] + self.api.mc_x4_read_video.restype = ctypes.c_int + self.api.mc_x4_close.argtypes = [ctypes.c_void_p] + self.api.mc_x4_close.restype = None + self.handle = self.api.mc_x4_open(binding.serial.encode(), str(log_directory).encode()) + if not self.handle: + raise RuntimeError("SDK could not open the selected X4") + self.buffer = (ctypes.c_uint8 * MAX_PACKET)() + + def call(self, action, values): + mode, key, number = parameters(action, values) + with self.lock: + if not self.handle: + raise RuntimeError("Camera session is closed") + raw = self.api.mc_x4_call(self.handle, ACTIONS[action], mode, key.encode(), number) + if not raw or len(raw) > 65536: + raise RuntimeError("Camera returned an invalid response") + result = json.loads(raw) + if not isinstance(result, dict) or result.get("state") not in ( + "complete", + "error", + "unknown", + ): + raise RuntimeError("Camera returned an invalid response") + return result + + def video(self): + # SDK control calls may block for seconds. The callback queue has its + # own native mutex and can be drained without taking the command lock. + with self.video_lock: + if not self.handle: + return None + header = VideoHeader() + state = self.api.mc_x4_read_video( + self.handle, ctypes.byref(header), self.buffer, MAX_PACKET + ) + if state < 0 or header.bytes > MAX_PACKET: + raise RuntimeError("Invalid video packet") + if state == 0: + return None + if header.stream_index not in (0, 1) or header.codec not in (0, 1) or not header.bytes: + raise RuntimeError("Unsupported video packet") + return {name: getattr(header, name) for name, _ in VideoHeader._fields_}, bytes( + self.buffer[: header.bytes] + ) + + def close(self): + with self.lock, self.video_lock: + if self.handle: + self.api.mc_x4_close(self.handle) + self.handle = None diff --git a/plugins/insta360-x4/runtime/operations.py b/plugins/insta360-x4/runtime/operations.py new file mode 100644 index 0000000..36693ab --- /dev/null +++ b/plugins/insta360-x4/runtime/operations.py @@ -0,0 +1,105 @@ +"""Per-camera durable effects. A retry retrieves a receipt, never replays START.""" + +import hashlib +import json +import os +import re +import tempfile +import threading +from datetime import UTC, datetime +from pathlib import Path + +from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest + +from .errors import MESSAGES +from .native import parameters + +UNKNOWN = { + "state": "unknown", + "error": "Результат команды не подтверждён. Обновите состояние камеры.", +} +INVALID = {"state": "error", "error": "Параметр или действие недоступны в текущем режиме камеры."} + + +def atomic(path, value): + fd, temporary = tempfile.mkstemp(prefix=".receipt-", dir=path.parent) + try: + with os.fdopen(fd, "w") as stream: + os.fchmod(stream.fileno(), 0o600) + json.dump(value, stream, ensure_ascii=False, allow_nan=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +class Operations: + def __init__(self, device_id, session_id, root, camera): + self.device_id, self.session_id = device_id, session_id + self.root, self.camera = Path(root), camera + self.lock = threading.Lock() + self.root.mkdir(mode=0o700, parents=True, exist_ok=True) + + def execute(self, value): + command = OperationRequest.model_validate(value) + if not re.fullmatch(r"op_[0-9a-f]{32}", command.operation_id): + raise ValueError("Invalid operation identity") + if command.session.device_id != self.device_id: + raise ValueError("Command belongs to another camera") + digest = hashlib.sha256( + json.dumps(value, sort_keys=True, allow_nan=False).encode() + ).hexdigest() + path = self.root / (command.operation_id + ".json") + with self.lock: + if path.exists(): + previous = json.loads(path.read_text()) + if previous["digest"] != digest: + raise ValueError("Operation identity is already in use") + return previous["result"] + if command.session.session_id != self.session_id: + raise ValueError("Camera session changed") + if command.deadline_at <= datetime.now(UTC): + raise ValueError("Command deadline expired") + params = dict(command.parameters) + parameters(command.action_id, params) + receipt = {"digest": digest, "result": dict(UNKNOWN)} + # This fsync must complete before the SDK receives the command. + # A crash between dispatch and acknowledgement remains UNKNOWN. + atomic(path, receipt) + try: + result = self.camera.call(command.action_id, params) + if result["state"] == "error": + error = result.get("error") + if not isinstance(error, str): + error = "" + result = { + "state": "error", + "error": error + if error in MESSAGES.values() + else MESSAGES.get(error, "Не удалось выполнить команду камеры."), + } + elif result["state"] == "unknown": + result = dict(UNKNOWN) + # settings.apply must verify the readback, not merely accept + # the SDK's SetXXX acknowledgement or its old cached value. + if command.action_id == "settings.apply" and result["state"] == "complete": + observed = result.get("result", {}) + actual = ( + observed.get("mode") + if params["key"] == "function_mode" + else observed.get("values", {}).get(params["key"]) + ) + if actual != params["value"]: + result = dict(UNKNOWN) + receipt["result"] = result + except (RuntimeError, ValueError, OSError, KeyError, TypeError): + receipt["result"] = dict(UNKNOWN) + atomic(path, receipt) + return receipt["result"] diff --git a/plugins/insta360-x4/runtime/verification.py b/plugins/insta360-x4/runtime/verification.py new file mode 100644 index 0000000..4f3a1b6 --- /dev/null +++ b/plugins/insta360-x4/runtime/verification.py @@ -0,0 +1,114 @@ +"""Bounded image verification; packets alone never count as a working camera.""" + +import time + +from .errors import failure +from .operations import UNKNOWN + + +class Control: + def __init__(self, camera, feed=None): + self.camera = camera + self.feed = feed + self.verified = False + + def call(self, action, values): + if action == "verify": + return self.verify() + if self.feed and action in ("preview.start", "preview.stop"): + # The native adapter treats an already reached state as a no-op. + # Preserve the same feed generation: clearing it would discard the + # only initial keyframe even though the camera sends no new START. + current = self.camera.call("details", {}) + target = 1 if action == "preview.start" else 0 + if ( + current.get("state") == "complete" + and current.get("result", {}).get("connected") is True + and current["result"].get("preview") == target + ): + return self.camera.call(action, values) + return self.feed.change(lambda: self.camera.call(action, values)) + return self.camera.call(action, values) + + def verify(self, timeout=8): + import av + + self.verified = False + result = self.camera.call("details", {}) + status = result.get("result", {}) + if ( + result.get("state") != "complete" + or status.get("connected") is not True + or status.get("recording") != 0 + or status.get("preview") not in (0, 1) + ): + return failure("verification_recording_active") + owned = status["preview"] == 0 + decoders, observed = {}, {} + outcome = failure("preview_no_decodable_image") + start = time.monotonic() + read_video = self.feed.reader() if self.feed else self.camera.video + try: + if owned and self.call("preview.start", {}).get("state") != "complete": + return dict(UNKNOWN) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = read_video() + if value is None: + time.sleep(0.01) + continue + header, data = value + index, codec, generation = ( + header["stream_index"], + header["codec"], + header["generation"], + ) + name = "h264" if codec == 0 else "hevc" if codec == 1 else None + if index not in (0, 1) or name is None: + return dict(UNKNOWN) + key = (index, codec, generation) + if header.get("gap") or index not in decoders or decoders[index][0] != key: + decoder = av.CodecContext.create(name, "r") + decoder.thread_count = 1 + decoders[index] = (key, decoder) + observed.pop(index, None) + decoder = decoders[index][1] + try: + for packet in decoder.parse(data): + for frame in decoder.decode(packet): + if not (0 < frame.width <= 4096 and 0 < frame.height <= 2160): + return dict(UNKNOWN) + current = observed.setdefault( + index, + { + "stream_index": index, + "codec": name, + "width": frame.width, + "height": frame.height, + "frames": 0, + }, + ) + current["frames"] += 1 + except av.error.FFmpegError: + decoders.pop(index, None) + observed.pop(index, None) + continue + if any(item["frames"] >= 2 for item in observed.values()): + outcome = { + "state": "complete", + "result": { + "verified": True, + "streams": list(observed.values()), + "duration_ms": round((time.monotonic() - start) * 1000), + }, + } + break + finally: + if owned: + try: + if self.call("preview.stop", {}).get("state") != "complete": + outcome = dict(UNKNOWN) + except (RuntimeError, ValueError, OSError): + outcome = dict(UNKNOWN) + self.verified = outcome["state"] == "complete" + return outcome diff --git a/plugins/insta360-x4/runtime/worker.py b/plugins/insta360-x4/runtime/worker.py new file mode 100644 index 0000000..b69da4a --- /dev/null +++ b/plugins/insta360-x4/runtime/worker.py @@ -0,0 +1,154 @@ +"""One SDK session per OS-isolated USB instance. No public network listener.""" + +import os +import pwd +import threading +import time +import uuid +from datetime import UTC, datetime +from pathlib import Path + +from .frames import Feed, encode +from .http import Binary, Server +from .identity import read_binding +from .lifecycle import acquisition +from .native import NativeCamera +from .operations import Operations +from .verification import Control + + +class Worker: + def __init__(self, binding, runtime, state): + self.binding = binding + self.session = "x4_" + uuid.uuid4().hex + self.opened_at = datetime.now(UTC).isoformat() + self.status_lock = threading.Lock() + self.status = {} + self.observed = 0.0 + self.observed_at = self.opened_at + self.revision = 0 + self.snapshot_signature = None + self.failure = None + self.ready = False + self.done = threading.Event() + self.camera = None + self.control = None + self.feed = None + self.operations = None + self.runtime, self.state = runtime, state + + def connect(self): + # Bounded startup even if a vendor call never returns. No restart loop: + # a new attempt requires explicit preparation or physical reconnection. + timer = threading.Timer(40, lambda: os._exit(70)) + timer.daemon = True + timer.start() + try: + logs = self.state / "sdk-logs" + logs.mkdir(mode=0o700, exist_ok=True) + # The prepare process starts services before releasing its lock. + # Wait for that bounded transaction without starting the SDK early. + with acquisition(wait=True): + self.camera = NativeCamera( + self.binding, self.runtime / "lib/libmissioncore_x4.so", logs + ) + self.feed = Feed(self.camera) + self.feed.thread.start() + self.control = Control(self.camera, self.feed) + self.operations = Operations( + self.binding.device_id, self.session, self.state / "operations", self.control + ) + self.refresh() + self.ready = True + except (RuntimeError, OSError, ValueError): + self.failure = "Не удалось открыть X4. Проверьте питание и режим USB на камере." + finally: + timer.cancel() + + def refresh(self): + if read_binding(self.binding.port) != self.binding: + raise RuntimeError("Camera transport changed") + response = self.camera.call("details", {}) + if response["state"] != "complete": + raise RuntimeError("Camera status is unconfirmed") + with self.status_lock: + self.status = response["result"] + self.observed = time.monotonic() + self.observed_at = datetime.now(UTC).isoformat() + self.revision += 1 + + def snapshot(self): + with self.status_lock: + state = dict(self.status) + fresh = time.monotonic() - self.observed < 12 + connected = self.ready and fresh and state.get("connected") is True + verified = bool(self.control and self.control.verified) + signature = (self.ready, connected, fresh, verified, self.failure) + if signature != self.snapshot_signature: + self.revision += 1 + self.snapshot_signature = signature + self.observed_at = datetime.now(UTC).isoformat() + revision, observed_at = self.revision, self.observed_at + return { + "id": self.binding.device_id, + "session_id": self.session, + "opened_at": self.opened_at, + "prepared": self.ready, + "online": connected, + "verified": verified, + "revision": revision, + "observed_at": observed_at, + "preparation_safe": connected + and state.get("preview") == 0 + and state.get("recording") == 0, + "status": state if fresh else {}, + "message": self.failure, + } + + def dispatch(self, method, route, value, headers): + if method == "GET" and route == "/snapshot": + return self.snapshot() + if method == "POST" and route == "/video": + if ( + not self.ready + or value.get("session_id") != self.session + or set(value) != {"session_id", "cursor"} + ): + raise ValueError("Camera video session changed") + return Binary(encode(self.feed.read(value["cursor"]))) + if method == "POST" and route == "/operation": + if not self.ready: + raise RuntimeError("Camera is unavailable") + with acquisition(): + result = self.operations.execute(value) + # Poll asynchronously; a lost state read must not change a durable + # acknowledged command result into a different receipt. + if value.get("action_id") not in ("details", "settings.read", "files.list"): + with self.status_lock: + self.observed = 0 + return result + raise ValueError("Unsupported worker route") + + def poll(self): + self.connect() + while self.ready and not self.done.wait(3): + try: + self.refresh() + except (RuntimeError, OSError, ValueError): + with self.status_lock: + self.observed = 0 + + +def main(port, runtime): + binding = read_binding(port) + state = Path(os.environ["STATE_DIRECTORY"]) + socket = Path(os.environ["RUNTIME_DIRECTORY"]) / "driver.sock" + worker = Worker(binding, runtime, state) + thread = threading.Thread(target=worker.poll, daemon=True) + thread.start() + if socket.exists(): + socket.unlink() + allowed = {0, pwd.getpwnam("mission-core-insta360").pw_uid} + with Server(socket, worker.dispatch, allowed) as server: + socket.chmod(0o660) + server.serve_forever(poll_interval=0.5) diff --git a/plugins/insta360-x4/tests/check_inventory.py b/plugins/insta360-x4/tests/check_inventory.py new file mode 100644 index 0000000..29d87bd --- /dev/null +++ b/plugins/insta360-x4/tests/check_inventory.py @@ -0,0 +1,75 @@ +"""Actual plugin-SDK and Core inventory validation, with synthetic identities.""" + +import json +import sys +import tempfile +import time +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO / "src")) +from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot # noqa: E402 +from runtime import broker, identity, worker # noqa: E402 + +from k1link.fleet.sensors import validate_inventory # noqa: E402 + +NODE = "node_" + "a" * 64 + + +class InventoryTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(dir=REPO / "plugins/insta360-x4/build") + self.addCleanup(self.temporary.cleanup) + root = Path(self.temporary.name) + binding = identity.Binding(identity.device_id("SYNTHETIC"), "1-2", 1, 2, "SYNTHETIC") + self.worker = worker.Worker(binding, root, root) + self.broker = broker.Broker() + + def test_idle_active_unknown_and_disconnected_match_real_session_contract(self): + revisions = [] + for ready, preview, recording in ( + (False, None, None), + (True, 0, 0), + (True, 1, 0), + (True, -1, -1), + (False, None, None), + ): + self.worker.ready = ready + self.worker.status = {"connected": ready, "preview": preview, "recording": recording} + self.worker.observed = time.monotonic() if ready else 0 + value = self.broker.item(self.worker.snapshot(), NODE) + snapshot = DeviceSessionSnapshot.model_validate(value["snapshot"]) + revisions.append(snapshot.revision) + validate_inventory({"devices": [value]}, NODE) + self.assertEqual(revisions, sorted(revisions)) + self.assertGreater(revisions[-1], revisions[0]) + + def test_500_distinct_cameras_fit_bounded_carrier_and_501_are_rejected(self): + self.worker.ready = True + self.worker.observed = time.monotonic() + self.worker.status = {"connected": True, "preview": 0, "recording": 0} + snapshot = self.worker.snapshot() + values = [] + for index in range(500): + value = dict(snapshot, id=identity.device_id("SYNTHETIC-" + str(index))) + values.append(self.broker.item(value, NODE)) + state = {"items": values, "operations": []} + payload = {"devices": values, "sensor_state": state} + self.assertIs(validate_inventory(payload, NODE), state) + self.assertLess(len(json.dumps({"items": values}).encode()), 2 * 1024 * 1024) + self.assertLess(len(json.dumps(payload).encode()), 8 * 1024 * 1024) + payload["devices"] = values + [values[0]] + with self.assertRaises(ValueError): + validate_inventory(payload, NODE) + + def test_cross_node_or_duplicate_identity_is_rejected(self): + value = self.broker.item(self.worker.snapshot(), NODE) + with self.assertRaises(ValueError): + validate_inventory({"devices": [value]}, "node_" + "b" * 64) + with self.assertRaises(ValueError): + validate_inventory({"devices": [value, value]}, NODE) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/plugins/insta360-x4/tests/check_media.py b/plugins/insta360-x4/tests/check_media.py new file mode 100644 index 0000000..244df8f --- /dev/null +++ b/plugins/insta360-x4/tests/check_media.py @@ -0,0 +1,413 @@ +"""Real codecs/WebRTC over loopback; synthetic frames, no CameraSDK or device.""" + +import asyncio +import os +import re +import tempfile +import threading +import unittest +from fractions import Fraction +from pathlib import Path +from unittest.mock import patch + +from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription +from check_runtime import ROOT, VideoCamera, synthetic_video +from runtime import broker, frames, http, media +from runtime.verification import Control + + +def packet(number, data=b"synthetic", generation=1): + return { + "stream_index": 0, + "codec": 0, + "generation": generation, + "sequence": number, + "timestamp": number, + }, data + + +class FrameTests(unittest.TestCase): + def test_repeated_preview_commands_preserve_generation_and_readers(self): + feed = frames.Feed(None) + camera = VideoCamera([], preview=1) + control = Control(camera, feed) + feed.append(packet(1)) + before = feed.read(0, 0) + self.assertEqual(control.call("preview.start", {})["state"], "complete") + self.assertEqual(feed.read(0, 0), before) + control.call("preview.stop", {}) + self.assertIsNone(feed.read(0, 0)) + generation = feed.generation + control.call("preview.stop", {}) + self.assertEqual(feed.generation, generation) + control.call("preview.start", {}) + self.assertGreater(feed.generation, generation) + + def test_uncertain_redundant_start_does_not_release_existing_decoder(self): + value, events = broker.Broker(), [] + identifier = "instax4_" + "a" * 32 + session = {"device_id": identifier, "session_id": "synthetic"} + + class Engine: + def capture(self, key, path, start): + events.append(start) + + value.media = Engine() + + def request(path, route, command=None, **kwargs): + if route == "/snapshot": + return { + "id": identifier, + "session_id": "synthetic", + "online": True, + "status": {"preview": 1}, + } + return {"state": "unknown"} + + with patch.object(broker, "request", request): + result = value.capture_operation( + identifier, {"session": session, "action_id": "preview.start"} + ) + self.assertEqual(result["state"], "unknown") + self.assertEqual(events, [True]) + + def test_broker_primes_before_sdk_start_and_releases_after_sdk_stop(self): + value = broker.Broker() + events = [] + identifier = "instax4_" + "a" * 32 + session = {"device_id": identifier, "session_id": "synthetic"} + + class Engine: + def capture(self, key, path, start): + events.append("prime" if start else "release") + + value.media = Engine() + + def request(path, route, command=None, **kwargs): + if route == "/snapshot": + return {"id": identifier, "session_id": "synthetic", "online": True} + events.append(command["action_id"]) + return {"state": "complete"} + + with patch.object(broker, "request", request): + for action in ("preview.start", "preview.stop"): + value.capture_operation(identifier, {"session": session, "action_id": action}) + self.assertEqual(events, ["prime", "preview.start", "preview.stop", "release"]) + + def test_late_decoder_gets_parameters_after_initial_packets_leave_queue(self): + import av + + encoder = av.CodecContext.create("libx264", "w") + encoder.width, encoder.height, encoder.pix_fmt = 32, 16, "yuv420p" + encoder.time_base = Fraction(1, 30) + encoder.options = { + "preset": "ultrafast", + "tune": "zerolatency", + "x264-params": "keyint=6:min-keyint=6:scenecut=0", + } + feed = frames.Feed(None) + for number in range(96): + frame = av.VideoFrame(32, 16, "yuv420p") + frame.pts = number + for plane in frame.planes: + plane.update(bytes([64]) * plane.buffer_size) + for encoded in encoder.encode(frame): + data = bytes(encoded) + if number: + # Model a camera which sends SPS/PPS only at preview start. + units = re.split(b"\x00\x00(?:\x00)?\x01", data) + data = b"".join( + b"\x00\x00\x00\x01" + unit + for unit in units + if unit and unit[0] & 31 not in (7, 8) + ) + feed.append(packet(number, data)) + self.assertEqual(len(feed.queue), frames.MAX_ENTRIES) + self.assertGreater(feed.queue[0][0]["sequence"], 0) + decoder, decoded, cursor = av.CodecContext.create("h264", "r"), [], 0 + while (value := feed.read(cursor, 0)) is not None: + header, data = value + cursor = header["cursor"] + try: + for encoded in decoder.parse(data): + decoded.extend(decoder.decode(encoded)) + except av.error.FFmpegError: + pass + self.assertGreaterEqual(len(decoded), 2) + self.assertEqual((decoded[-1].width, decoded[-1].height), (32, 16)) + + def test_parameter_cache_cannot_cross_stream_device_or_preview_generation(self): + config = b"\x00\x00\x01\x67sps\x00\x00\x01\x68pps" + keyframe = b"\x00\x00\x01\x65frame" + first, second = frames.Feed(None), frames.Feed(None) + first.append(packet(1, config)) + first.append(packet(2, keyframe)) + self.assertEqual(first.queue[-1][1], config + keyframe) + second.append(packet(1, keyframe)) + self.assertEqual(second.queue[-1][1], keyframe) + header, data = packet(3, keyframe) + header["stream_index"] = 1 + first.append((header, data)) + self.assertEqual(first.queue[-1][1], keyframe) + first.append(packet(4, keyframe, generation=2)) + self.assertEqual(first.queue[-1][1], keyframe) + first.append(packet(5, config, generation=2)) + first.clear() + first.append(packet(6, keyframe, generation=2)) + self.assertEqual(first.queue[-1][1], keyframe) + + def test_parameter_cache_is_bounded_and_hevc_parameters_are_distinct(self): + feed = frames.Feed(None) + huge = b"\x00\x00\x01\x67" + b"x" * frames.MAX_PARAMETERS + feed.append(packet(1, huge)) + self.assertFalse(any(feed.parameters.streams.values())) + config = b"".join(b"\x00\x00\x01" + bytes([kind << 1, 1, 2]) for kind in (32, 33, 34)) + keyframe = b"\x00\x00\x01\x26\x01frame" + header, _ = packet(2) + header["codec"] = 1 + feed.append((header, config)) + feed.append((header, keyframe)) + self.assertEqual(feed.queue[-1][1], config + keyframe) + + def test_independent_readers_and_devices_preserve_packet_identity(self): + first, second = frames.Feed(None), frames.Feed(None) + a, b = first.reader(), first.reader() + first.append(packet(1, b"first")) + second.append(packet(1, b"second")) + self.assertEqual(a()[1], b"first") + self.assertEqual(b()[1], b"first") + self.assertEqual(second.read(0, 0)[1], b"second") + self.assertIsNone(a()) + + def test_overrun_and_preview_boundary_are_visible_to_decoder(self): + feed = frames.Feed(None) + for n in range(100): + feed.append(packet(n)) + value = feed.read(1, 0) + self.assertTrue(value[0]["gap"]) + self.assertLessEqual(len(feed.queue), frames.MAX_ENTRIES) + old = value[0]["generation"] + feed.change(lambda: None) + feed.append(packet(101)) + self.assertGreater(feed.read(0, 0)[0]["generation"], old) + + def test_wire_bounds_and_types_reject_corrupted_or_unknown_packets(self): + feed = frames.Feed(None) + feed.append(packet(1)) + value = feed.read(0, 0) + self.assertEqual(frames.decode(frames.encode(value)), value) + for data in (b"x", b"\xff" * 8, b"\0\0\0\1x", frames.encode(value)[:5]): + with self.assertRaises((ValueError, UnicodeError)): + frames.decode(data) + with self.assertRaises(ValueError): + feed.read(True, 0) + + def test_public_ice_candidates_are_rejected_before_network_work(self): + with self.assertRaises(ValueError): + media.admit_sdp("v=0\r\na=candidate:1 1 udp 1 203.0.113.6 1234 typ host\r\n") + media.admit_sdp("v=0\r\na=candidate:1 1 udp 1 127.0.0.1 1234 typ host\r\n") + + +class MediaTests(unittest.IsolatedAsyncioTestCase): + async def test_primed_decoder_survives_late_viewer_and_peer_close(self): + with tempfile.TemporaryDirectory(dir=ROOT / "build") as temporary: + descriptor = os.open(temporary, os.O_RDONLY) + path = Path(f"/proc/self/fd/{descriptor}/video.sock") + feed, stop = frames.Feed(None), threading.Event() + packets = synthetic_video(220) # One initial keyframe; >64-packet queue. + + def publish(): + for number, data in enumerate(packets): + if stop.is_set(): + break + feed.append(packet(number, data)) + stop.wait(1 / 30) + + def dispatch(method, route, value, headers): + if route == "/snapshot": + return {"session_id": "synthetic", "online": True, "status": {"preview": 1}} + if method != "POST" or route != "/video" or value["session_id"] != "synthetic": + raise ValueError("Unexpected source command") + return http.Binary(frames.encode(feed.read(value["cursor"]))) + + server = http.Server(path, dispatch, {os.geteuid()}) + serving = threading.Thread(target=server.serve_forever, daemon=True) + producer = threading.Thread(target=publish, daemon=True) + serving.start() + peers, key = media.Peers(), ("synthetic-camera", "synthetic") + receiver = RTCPeerConnection(RTCConfiguration(iceServers=[])) + received = asyncio.get_running_loop().create_future() + tasks = [] + + @receiver.on("track") + def track(value): + async def observe(): + first, second = await value.recv(), await value.recv() + if not received.done(): + received.set_result((first, second)) + + tasks.append(asyncio.create_task(observe())) + + try: + await peers.prime(key, path) + source = peers.sources[key] + producer.start() + await asyncio.sleep(2.4) + self.assertGreater(feed.queue[0][0]["sequence"], 0) + self.assertGreater(source.current()[0], 2) + with patch.object(media, "_host_addresses", return_value=["127.0.0.1"]): + receiver.addTransceiver("video", direction="recvonly") + receiver.createDataChannel("sensor") + await receiver.setLocalDescription(await receiver.createOffer()) + answer = await peers.offer( + key, path, {"sdp": receiver.localDescription.sdp, "layer": "preview"} + ) + self.assertNotIn("VP8/", answer["sdp"]) + await receiver.setRemoteDescription( + RTCSessionDescription(sdp=answer["sdp"], type="answer") + ) + first, second = await asyncio.wait_for(received, 4) + self.assertEqual((first.width, first.height), (32, 16)) + self.assertGreater(second.pts, first.pts) + # A second UI can send START from its stale idle snapshot. + # No new camera keyframe is produced for this idempotent call. + control = Control(VideoCamera([], preview=1), feed) + control.call("preview.start", {}) + verified = await peers.verify(key, timeout=1) + self.assertEqual(verified["state"], "complete") + self.assertGreaterEqual(verified["result"]["streams"][0]["frames"], 2) + await peers.close(key, answer["peer_id"]) + self.assertIs(peers.sources[key], source) + self.assertFalse(source.closed.is_set()) + finally: + await receiver.close() + await peers.release(key) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + stop.set() + if producer.is_alive(): + producer.join(2) + feed.close() + server.shutdown() + server.server_close() + serving.join(2) + os.close(descriptor) + self.assertFalse(peers.items) + self.assertFalse(peers.sources) + self.assertFalse(peers.primed) + + async def test_verification_never_accepts_retained_frames_or_another_camera(self): + peers = media.Peers() + key = ("synthetic", "one") + + class Retained: + closed = threading.Event() + + def current(self): + return 50, object(), media.time.monotonic() + + retained = Retained() + peers.sources[key] = retained + self.assertEqual((await peers.verify(key, timeout=0.05))["state"], "error") + self.assertEqual((await peers.verify(("another", "one")))["state"], "error") + retained.closed.set() + self.assertEqual((await peers.verify(key))["state"], "error") + + async def test_synthetic_frames_cross_private_ipc_and_real_webrtc_with_cleanup(self): + self.loop_slow_callback_duration = 1 + with tempfile.TemporaryDirectory(dir=ROOT / "build") as temporary: + directory = Path(temporary) + descriptor = os.open(directory, os.O_RDONLY) + path = Path(f"/proc/self/fd/{descriptor}/video.sock") + feed = frames.Feed(None) + stop = threading.Event() + packets = synthetic_video() + + def publish(): + number = 0 + while not stop.is_set(): + feed.append( + packet( + number, + packets[number % len(packets)], + generation=number // len(packets) + 1, + ) + ) + number += 1 + stop.wait(1 / 30) + + def dispatch(method, route, value, headers): + if method != "POST" or route != "/video" or value["session_id"] != "synthetic": + raise ValueError("Wrong camera/session") + return http.Binary(frames.encode(feed.read(value["cursor"]))) + + server = http.Server(path, dispatch, {os.geteuid()}) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + producer = threading.Thread(target=publish, daemon=True) + server_thread.start() + producer.start() + peers = media.Peers() + receiver = RTCPeerConnection(RTCConfiguration(iceServers=[])) + received = asyncio.get_running_loop().create_future() + tasks = [] + identifier = None + key = ("synthetic-camera", "synthetic") + + @receiver.on("track") + def track(value): + async def observe(): + first = await value.recv() + second = await value.recv() + if not received.done(): + received.set_result((first, second)) + + tasks.append(asyncio.create_task(observe())) + + try: + # Both peers use only 127.0.0.1. No STUN, LAN candidates or + # camera packet is admitted to this synthetic qualification. + with patch.object(media, "_host_addresses", return_value=["127.0.0.1"]): + receiver.addTransceiver("video", direction="recvonly") + channel = receiver.createDataChannel("sensor") + + @channel.on("open") + def opened(): + channel.send("keepalive") + + await receiver.setLocalDescription(await receiver.createOffer()) + answer = await peers.offer( + key, path, {"sdp": receiver.localDescription.sdp, "layer": "preview"} + ) + identifier = answer["peer_id"] + await receiver.setRemoteDescription( + RTCSessionDescription(sdp=answer["sdp"], type="answer") + ) + first, second = await asyncio.wait_for(received, 12) + self.assertEqual((first.width, first.height), (32, 16)) + self.assertGreater(second.pts, first.pts) + with self.assertRaises(ValueError): + await peers.close(("another-camera", "synthetic"), identifier) + self.assertIn(identifier, peers.items) + finally: + await receiver.close() + if identifier: + await peers.close(key, identifier) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + stop.set() + feed.close() + server.shutdown() + server.server_close() + server_thread.join(2) + producer.join(2) + os.close(descriptor) + self.assertFalse(peers.items) + self.assertFalse(peers.sources) + self.assertFalse(server_thread.is_alive()) + self.assertFalse(producer.is_alive()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/plugins/insta360-x4/tests/check_profile.py b/plugins/insta360-x4/tests/check_profile.py new file mode 100644 index 0000000..43fd4a0 --- /dev/null +++ b/plugins/insta360-x4/tests/check_profile.py @@ -0,0 +1,164 @@ +"""Synthetic cold-host bootstrap transactions; no actual APT/systemd/USB.""" + +import hashlib +import importlib.util +import json +import subprocess +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +REPO = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location( + "x4_node_profile", REPO / "apps/node-agent/packaging/insta360_profile.py" +) +profile = importlib.util.module_from_spec(spec) +spec.loader.exec_module(profile) + + +class ProfileTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(dir=REPO / "plugins/insta360-x4/build") + self.addCleanup(self.temp.cleanup) + root = Path(self.temp.name) + self.share, self.state, self.plugin = (root / name for name in ("share", "state", "plugin")) + for directory in (self.share, self.state, self.plugin): + directory.mkdir() + self.bundle = { + "schema": "missioncore.node.bundled-model/v1", + "version": "0.1.1", + "revision": "a" * 24, + "bytes": 3, + "sha256": hashlib.sha256(b"deb").hexdigest(), + } + (self.share / "profile.json").write_text(json.dumps(self.bundle)) + (self.share / "mission-core-insta360-x4_0.1.1_amd64.deb").write_bytes(b"deb") + self.calls = [] + self.previous = None + self.failure = False + for name, value in ( + ("SHARE", self.share), + ("STATE", self.state), + ("PLUGIN_STATE", self.plugin), + ): + context = patch.object(profile, name, value) + context.start() + self.addCleanup(context.stop) + + # Production checks uid 0. Tests remain unprivileged and only admit the + # three private fixture directories, never paths on the installed OS. + def trusted(path, directory=False): + self.assertTrue(path == root or root in path.parents) + self.assertFalse(path.is_symlink()) + self.assertEqual(path.is_dir(), directory) + return path + + for context in ( + patch.object(profile, "trusted", trusted), + patch.object(profile.platform, "machine", return_value="x86_64"), + patch.object( + profile.platform, + "freedesktop_os_release", + return_value={"ID": "ubuntu", "VERSION_ID": "24.04"}, + ), + patch.object(profile.subprocess, "run", side_effect=self.run_command), + ): + context.start() + self.addCleanup(context.stop) + + def prepared(self): + (self.plugin / "preparation.json").write_text( + json.dumps( + { + "state": "complete", + "revision": self.bundle["revision"], + "started_at": time.time(), + } + ) + ) + + def run_command(self, command, **kwargs): + self.calls.append(command) + name = Path(command[0]).name + if name == "dpkg-query": + return subprocess.CompletedProcess( + command, + 1 if self.previous is None else 0, + "" if self.previous is None else self.previous + "\tinstall ok installed", + "", + ) + if name == "dpkg" and command[1] == "--compare-versions": + return subprocess.CompletedProcess(command, 0 if self.previous == "0.2.0" else 1) + if name == "dpkg" and command[1] == "--install": + if self.failure: + return subprocess.CompletedProcess(command, 100, b"", b"synthetic package refusal") + if self.previous: + self.prepared() # Upgrade postinst owns preparation. + elif name == "systemctl": + self.assertEqual(command, ["/usr/bin/systemctl", "start", profile.UNIT]) + self.prepared() + else: + self.fail("Unexpected OS command") + return subprocess.CompletedProcess(command, 0, b"", b"") + + def names(self): + return [Path(command[0]).name for command in self.calls] + + def test_cold_host_installs_only_bundled_package_then_fixed_prepare(self): + self.assertTrue(profile.prepare()) + self.assertEqual(self.names(), ["dpkg-query", "dpkg", "systemctl"]) + self.assertEqual( + self.calls[1], + [ + "/usr/bin/dpkg", + "--install", + str(self.share / "mission-core-insta360-x4_0.1.1_amd64.deb"), + ], + ) + + def test_existing_version_reuses_package_and_revalidates_runtime(self): + self.previous = "0.1.1" + self.assertTrue(profile.prepare()) + self.assertFalse(any("--install" in command for command in self.calls)) + self.assertIn("systemctl", self.names()) + + def test_debian_revision_remains_a_fixed_local_package(self): + old = self.share / "mission-core-insta360-x4_0.1.1_amd64.deb" + old.rename(self.share / "mission-core-insta360-x4_0.1.1-1_amd64.deb") + self.bundle["version"] = "0.1.1-1" + (self.share / "profile.json").write_text(json.dumps(self.bundle)) + self.assertTrue(profile.prepare()) + self.assertEqual(Path(self.calls[1][-1]).name, "mission-core-insta360-x4_0.1.1-1_amd64.deb") + + def test_upgrade_does_not_race_postinst_with_second_prepare(self): + self.previous = "0.1.0" + self.assertTrue(profile.prepare()) + self.assertTrue(any("--install" in command for command in self.calls)) + self.assertNotIn("systemctl", self.names()) + + def test_corruption_never_reaches_package_manager_or_driver(self): + (self.share / "mission-core-insta360-x4_0.1.1_amd64.deb").write_bytes(b"bad") + self.assertFalse(profile.prepare()) + self.assertEqual(self.calls, []) + + def test_newer_driver_is_preserved(self): + self.previous = "0.2.0" + self.assertFalse(profile.prepare()) + self.assertFalse(any("--install" in command for command in self.calls)) + self.assertNotIn("systemctl", self.names()) + + def test_failed_package_never_opens_camera_and_keeps_error_evidence(self): + self.failure = True + self.assertFalse(profile.prepare()) + self.assertNotIn("systemctl", self.names()) + report = json.loads((self.state / "preparation.json").read_text()) + self.assertEqual(report["steps"][-1]["state"], "blocked") + self.assertEqual( + (self.state / report["run_id"] / "1.stderr").read_bytes(), b"synthetic package refusal" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/insta360-x4/tests/check_runtime.py b/plugins/insta360-x4/tests/check_runtime.py new file mode 100644 index 0000000..af3afb7 --- /dev/null +++ b/plugins/insta360-x4/tests/check_runtime.py @@ -0,0 +1,349 @@ +"""Qualification on Ubuntu using a freshly extracted pinned Python runtime. + +Only fake camera calls are executed. No vendor SDK import, USB or system change. +""" + +import hashlib +import io +import os +import sys +import tempfile +import threading +import time +import unittest +import uuid +import zipfile +from datetime import UTC, datetime, timedelta +from fractions import Fraction +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT), str(ROOT / "packaging")] +import prepare # noqa: E402 +from runtime import broker, http, identity, operations, worker # noqa: E402 +from runtime.verification import Control # noqa: E402 + + +class VideoCamera: + def __init__(self, packets, preview=0, recording=0, stop_unknown=False): + self.packets = list(packets) + self.preview, self.recording = preview, recording + self.stop_unknown = stop_unknown + self.calls = [] + self.sequence = 0 + + def call(self, action, values): + self.calls.append(action) + if action == "details": + return { + "state": "complete", + "result": {"connected": True, "preview": self.preview, "recording": self.recording}, + } + if action == "preview.start": + self.preview = 1 + if action == "preview.stop": + self.preview = 0 + if self.stop_unknown: + return {"state": "unknown"} + return {"state": "complete", "result": {"ok": True}} + + def video(self): + if not self.packets: + return None + self.sequence += 1 + return { + "stream_index": 0, + "codec": 0, + "generation": 1, + "sequence": self.sequence, + }, self.packets.pop(0) + + +def synthetic_video(count=4): + import av + + encoder = av.CodecContext.create("libx264", "w") + encoder.width, encoder.height = 32, 16 + encoder.pix_fmt = "yuv420p" + encoder.time_base = Fraction(1, 30) + encoder.options = {"preset": "ultrafast", "tune": "zerolatency"} + output = [] + for number in range(count): + frame = av.VideoFrame(32, 16, "yuv420p") + frame.pts = number + for index, plane in enumerate(frame.planes): + plane.update(bytes([16 if index == 0 else 128]) * plane.buffer_size) + output.extend(bytes(packet) for packet in encoder.encode(frame)) + output.extend(bytes(packet) for packet in encoder.encode(None)) + return output + + +class Camera: + def __init__(self): + self.calls = [] + + def call(self, action, values): + self.calls.append((action, values)) + return {"state": "complete", "result": {"ok": True}} + + +def command(ident, session, action="record.start"): + now = datetime.now(UTC) + operation = "op_" + uuid.uuid4().hex + return { + "api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", + "kind": "OperationRequest", + "operation_id": operation, + "idempotency_key": operation, + "session": {"device_id": ident, "session_id": session}, + "action_id": action, + "requested_at": now.isoformat(), + "deadline_at": (now + timedelta(seconds=60)).isoformat(), + "parameters": {}, + } + + +class RuntimeTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(dir=ROOT / "build") + self.root = Path(self.temporary.name) + + def tearDown(self): + self.temporary.cleanup() + + def test_verification_uses_live_source_and_deduplicates_across_preview_states(self): + value = broker.Broker() + ident, session = identity.device_id("SYNTHETIC-A"), "session_one" + current = { + "id": ident, + "session_id": session, + "online": True, + "status": {"preview": 1, "recording": 0}, + } + calls = [] + + class Engine: + def verify(self, key): + calls.append(key) + return {"state": "complete", "result": {"verified": True}} + + value.media = Engine() + sdk = [] + + def request(path, route, command=None, **kwargs): + if route == "/snapshot": + return current + sdk.append(command) + return {"state": "complete", "result": {"verified": True}} + + first = command(ident, session, "verify") + with patch.object(broker, "STATE", self.root), patch.object(broker, "request", request): + self.assertEqual(value.verify_operation(ident, first)["state"], "complete") + current["status"]["preview"] = 0 + self.assertEqual(value.verify_operation(ident, first)["state"], "complete") + self.assertEqual(len(calls), 1) + self.assertFalse(sdk) + second = command(ident, session, "verify") + self.assertEqual(value.verify_operation(ident, second)["state"], "complete") + self.assertEqual(sdk, [second]) + current["status"]["recording"] = 1 + refused = value.verify_operation(ident, command(ident, session, "verify")) + self.assertIn("остановите запись", refused["error"]) + self.assertEqual(len(sdk), 1) + + def test_error_translation_preserves_verification_reason_and_redacts_vendor_text(self): + class Failed: + error = "preview_no_decodable_image" + + def call(self, action, params): + return {"state": "error", "error": self.error} + + camera = Failed() + ident = identity.device_id("SYNTHETIC-A") + runtime = operations.Operations(ident, "session_one", self.root, camera) + result = runtime.execute(command(ident, "session_one", "verify")) + self.assertEqual(result["error"], "Камера не передала декодируемое изображение.") + camera.error = "PRIVATE VENDOR DEVICE IDENTIFIER" + result = runtime.execute(command(ident, "session_one", "verify")) + self.assertEqual(result["error"], "Не удалось выполнить команду камеры.") + camera.error = "unsupported_camera_parameter" + result = runtime.execute(command(ident, "session_one", "verify")) + self.assertEqual(result, operations.INVALID) + + def test_receipt_deduplicates_start_after_worker_recreation(self): + camera = Camera() + ident = identity.device_id("SYNTHETIC-A") + original = operations.Operations(ident, "session_one", self.root, camera) + value = command(ident, "session_one") + self.assertEqual(original.execute(value)["state"], "complete") + resumed = operations.Operations(ident, "session_two", self.root, camera) + self.assertEqual(resumed.execute(value)["state"], "complete") + self.assertEqual(len(camera.calls), 1) + + def test_two_instances_never_share_commands_or_receipts(self): + first, second = Camera(), Camera() + a, b = identity.device_id("SYNTHETIC-A"), identity.device_id("SYNTHETIC-B") + left = operations.Operations(a, "session_a", self.root / "a", first) + right = operations.Operations(b, "session_b", self.root / "b", second) + value = command(a, "session_a") + left.execute(value) + with self.assertRaises(ValueError): + right.execute(value) + self.assertEqual(len(first.calls), 1) + self.assertEqual(second.calls, []) + + def test_expired_or_stale_session_has_no_sdk_effect(self): + camera = Camera() + ident = identity.device_id("SYNTHETIC-A") + runtime = operations.Operations(ident, "session_two", self.root, camera) + for value in [command(ident, "session_one"), command(ident, "session_two")]: + if value["session"]["session_id"] == "session_two": + value["requested_at"] = (datetime.now(UTC) - timedelta(seconds=90)).isoformat() + value["deadline_at"] = (datetime.now(UTC) - timedelta(seconds=10)).isoformat() + with self.assertRaises(ValueError): + runtime.execute(value) + self.assertEqual(camera.calls, []) + + def test_sd_recording_and_unknown_status_block_profile_replacement(self): + binding = identity.Binding(identity.device_id("SYNTHETIC"), "1-2", 1, 2, "SYNTHETIC") + instance = worker.Worker(binding, self.root, self.root) + instance.ready = True + for recording, preview, expected in [ + (0, 0, True), + (1, 0, False), + (-1, 0, False), + (0, 1, False), + ]: + instance.status = {"connected": True, "recording": recording, "preview": preview} + instance.observed = time.monotonic() + self.assertEqual(instance.snapshot()["preparation_safe"], expected) + instance.observed = 0 + self.assertFalse(instance.snapshot()["preparation_safe"]) + + def test_installer_fails_closed_when_worker_exists_but_broker_is_missing(self): + instances = self.root / "instances" + instances.mkdir() + (instances / identity.device_id("SYNTHETIC")).mkdir() + with ( + patch.object(prepare, "SOCKET", self.root / "absent.sock"), + patch.object(prepare, "INSTANCES", instances), + self.assertRaises(RuntimeError), + ): + prepare.assert_safe() + + def test_installer_refuses_active_recording_even_if_preview_idle(self): + socket = self.root / "driver.sock" + socket.touch() + with ( + patch.object(prepare, "SOCKET", socket), + patch.object(prepare, "request", return_value={"safe": False}), + self.assertRaises(RuntimeError), + ): + prepare.assert_safe() + + def test_runtime_integrity_rechecks_existing_install(self): + files = {"lib/synthetic.so": b"SYNTHETIC-NOT-AN-ELF", "python/example.py": b"value = 1\n"} + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + for name, content in files.items(): + archive.writestr(name, content) + payload = data.getvalue() + share, state = self.root / "share", self.root / "state" + share.mkdir() + state.mkdir() + (share / "payload.zip").write_bytes(payload) + bundle = { + "revision": "a" * 24, + "payload_sha256": hashlib.sha256(payload).hexdigest(), + "files": { + name: {"bytes": len(content), "sha256": hashlib.sha256(content).hexdigest()} + for name, content in files.items() + }, + } + + def directory(path): + path.mkdir(exist_ok=True) + + with ( + patch.object(prepare, "STATE", state), + patch.object(prepare, "SHARE", share), + patch.object(prepare, "trusted", side_effect=lambda path, directory=False: path), + patch.object(prepare, "directory", side_effect=directory), + ): + ident = prepare.install_runtime(bundle) + self.assertEqual(prepare.install_runtime(bundle), ident) + (state / "runtime" / ident / "python/example.py").write_bytes(b"modified") + with self.assertRaises(RuntimeError): + prepare.install_runtime(bundle) + + def test_payload_rejects_traversal_before_extraction(self): + data = io.BytesIO() + with zipfile.ZipFile(data, "w") as archive: + archive.writestr("../escape", b"synthetic") + info = {"bytes": 9, "sha256": hashlib.sha256(b"synthetic").hexdigest()} + with ( + zipfile.ZipFile(io.BytesIO(data.getvalue())) as archive, + self.assertRaises(RuntimeError), + ): + list(prepare.entries(archive, {"files": {"../escape": info}})) + self.assertFalse((self.root.parent / "escape").exists()) + + def test_private_socket_checks_kernel_peer_credentials(self): + # Keep the socket in owned staging through a short directory-FD alias; + # Linux sockaddr_un cannot hold the long build-source pathname. + descriptor = os.open(self.root, os.O_RDONLY) + self.addCleanup(os.close, descriptor) + for allowed, success in [({os.geteuid()}, True), ({os.geteuid() + 10000}, False)]: + path = Path(f"/proc/self/fd/{descriptor}") / ("yes.sock" if success else "no.sock") + server = http.Server(path, lambda *_: {"ok": True}, allowed) + thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}) + thread.start() + try: + if success: + self.assertTrue(http.request(path, "/snapshot")["ok"]) + else: + with self.assertRaises((OSError, http.http.client.HTTPException)): + http.request(path, "/snapshot") + finally: + server.shutdown() + server.server_close() + thread.join(2) + self.assertFalse(thread.is_alive()) + + def test_worker_routes_never_accept_arbitrary_device_paths(self): + for value in ("../another.sock", "/run/driver.sock", "instax4_bad", None): + with self.assertRaises(ValueError): + broker.worker_socket(value) + + def test_verification_requires_decoded_images_and_only_closes_own_preview(self): + camera = VideoCamera(synthetic_video()) + control = Control(camera) + result = control.verify(timeout=1) + self.assertEqual(result["state"], "complete") + self.assertTrue(control.verified) + self.assertGreaterEqual(result["result"]["streams"][0]["frames"], 2) + self.assertEqual(result["result"]["streams"][0]["width"], 32) + self.assertEqual(camera.calls, ["details", "preview.start", "preview.stop"]) + existing = VideoCamera(synthetic_video(), preview=1) + self.assertEqual(Control(existing).verify(timeout=1)["state"], "complete") + self.assertEqual(existing.calls, ["details"]) + + def test_packets_and_unconfirmed_preview_cleanup_are_not_verified(self): + broken = VideoCamera([b"SYNTHETIC-NOT-VIDEO"]) + control = Control(broken) + self.assertEqual(control.verify(timeout=0.03)["state"], "error") + self.assertFalse(control.verified) + uncertain = VideoCamera(synthetic_video(), stop_unknown=True) + control = Control(uncertain) + self.assertEqual(control.verify(timeout=1)["state"], "unknown") + self.assertFalse(control.verified) + + def test_image_verification_does_not_interrupt_sd_recording(self): + camera = VideoCamera([], recording=1) + self.assertEqual(Control(camera).verify(timeout=0.03)["state"], "error") + self.assertEqual(camera.calls, ["details"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/src/k1link/fleet/recovery.py b/src/k1link/fleet/recovery.py new file mode 100644 index 0000000..265ab91 --- /dev/null +++ b/src/k1link/fleet/recovery.py @@ -0,0 +1,162 @@ +"""Recover only an existing binding over a previously authenticated tailnet address. + +No peer discovery, invitation secrets, trust replacement, or provider API. The +Node pins the Core CA public key through mTLS; Core pins the saved Node key. +""" + +from __future__ import annotations + +import http.client +import ipaddress +import json +import ssl +import time + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from .trust import PairingError, atomic_private, endpoint, pem, verify_node_certificate + +SCHEMA = "missioncore.node-channel-recovery/v1" + + +def tailnet_address(value): + try: + return ipaddress.IPv4Address(value) in ipaddress.ip_network("100.64.0.0/10") + except (ValueError, TypeError): + return False + + +def node_addresses(row): + addresses = set() + for network in (row.get("inventory") or {}).get("networks", [])[:64]: + if not isinstance(network, dict) or not network.get("up"): + continue + for alias in network.get("addresses", [])[:32]: + if isinstance(alias, str) and tailnet_address(alias.split("/")[0]): + addresses.add(alias.split("/")[0]) + return sorted(addresses)[:2] + + +def client_context(trust): + # Keep the pinned Core key, but distinguish the leaf from its issuer. + # Go rejects same-subject/same-SPKI certificates without SAN as a chain loop. + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Mission Core channel recovery")]) + cert = ( + trust.builder(subject, trust.key.public_key(), 1) + .issuer_name(trust.ca.subject) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False) + .sign(trust.key, None) + ) + cert_path, key_path = trust.root / "recovery-client.pem", trust.root / "recovery-key.pem" + atomic_private(cert_path, (pem(cert) + pem(trust.ca)).encode()) + atomic_private( + key_path, + trust.key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ), + ) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.minimum_version = ssl.TLSVersion.TLSv1_3 + # Bootstrap certificate is self-signed by the already pinned Node key. + # verify_node_certificate must succeed before any application request. + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + context.load_cert_chain(cert_path, key_path) + return context + + +def request(connection, path, body): + connection.request("POST", path, json.dumps(body), {"Content-Type": "application/json"}) + response = connection.getresponse() + raw = response.read(16385) + if response.status != 200 or len(raw) > 16384: + raise PairingError("БК пока не подтвердил восстановление канала.") + value = json.loads(raw) + if not isinstance(value, dict): + raise PairingError("Некорректное подтверждение канала.") + return value + + +def recover(registry, row, address, context): + if not tailnet_address(address) or address not in node_addresses(row): + raise PairingError("Нет подтверждённого адреса БК.") + connection = http.client.HTTPSConnection(address, 8781, timeout=4, context=context) + try: + connection.connect() + verify_node_certificate(connection.sock.getpeercert(binary_form=True), row["node_id"]) + local = connection.sock.getsockname()[0] + if not tailnet_address(local): + raise PairingError("Обратный адрес Core не относится к частному каналу.") + body = {"schema": SCHEMA, "binding_id": row["binding"]["binding_id"]} + value = request(connection, "/v1/channel/inspect", body) + if ( + value.get("schema") != SCHEMA + or value.get("node_id") != row["node_id"] + or value.get("core_id") != registry.trust.core_id + or value.get("binding_id") != body["binding_id"] + or type(value.get("endpoint_revision")) is not int + or not 0 <= value["endpoint_revision"] < 1000000000 + ): + raise PairingError("Подтверждена другая привязка БК.") + endpoint(value.get("endpoint"), 8782) + with registry.lock: + current = registry.find(row["id"]) + if ( + registry.stop.is_set() + or current["enrollment"] != "paired" + or current["binding"]["binding_id"] != body["binding_id"] + ): + return + registry.listen(local) + desired = f"https://{local}:8782" + if value["endpoint"] != desired: + request( + connection, + "/v1/channel/migrate", + { + **body, + "expected_endpoint": value["endpoint"], + "expected_revision": value["endpoint_revision"], + "endpoint": desired, + }, + ) + # Only a subsequent authenticated heartbeat proves the new endpoint. + # Lost migration acknowledgements are resolved by inspect, never replay. + finally: + connection.close() + + +def reconcile(registry): + attempts = {} + context, refresh = None, 0 + while not registry.stop.is_set(): + with registry.lock: + rows = [r for r in registry.rows() if r["enrollment"] == "paired"] + attempts = {key: value for key, value in attempts.items() if key in {r["id"] for r in rows}} + for row in rows: + if registry.stop.is_set(): + return + now = time.monotonic() + if now < attempts.get(row["id"], 0): + continue + attempts[row["id"]] = now + 30 + if tailnet_address(row["core_address"]) and (row.get("last_seen") or 0) >= max( + registry.started_at, time.time() - 20 + ): + continue + for address in node_addresses(row): + try: + if context is None or now >= refresh: + context, refresh = client_context(registry.trust), now + 12 * 3600 + recover(registry, row, address, context) + break + except Exception: + # Bounded retry, no untrusted peer data or private addresses in logs. + # The ordinary heartbeat remains the connectivity authority. + continue + registry.stop.wait(2) diff --git a/src/k1link/fleet/registry.py b/src/k1link/fleet/registry.py index 04fdda9..ded58d9 100644 --- a/src/k1link/fleet/registry.py +++ b/src/k1link/fleet/registry.py @@ -54,6 +54,7 @@ class FleetRegistry: self.listeners: dict[str, object] = {} self.stop = threading.Event() self.worker: threading.Thread | None = None + self.recovery_worker: threading.Thread | None = None def rows(self): return [ @@ -86,11 +87,22 @@ class FleetRegistry: target=self.reconcile, name="mission-core-fleet", daemon=True ) self.worker.start() + from .recovery import reconcile as recover_channels + + self.recovery_worker = threading.Thread( + target=recover_channels, + args=(self,), + name="mission-core-channel-recovery", + daemon=True, + ) + self.recovery_worker.start() def close(self): self.stop.set() if self.worker: self.worker.join(timeout=20) + if self.recovery_worker: + self.recovery_worker.join(timeout=30) for server in self.listeners.values(): server.shutdown() server.server_close() @@ -341,7 +353,7 @@ class FleetRegistry: self.save(row) self.stop.wait(2) - def receive(self, certificate: bytes, path: str, value: dict): + def receive(self, certificate: bytes, path: str, value: dict, *, address: str | None = None): cert = x509.load_der_x509_certificate(certificate) key = cert.public_key() if not isinstance(key, Ed25519PublicKey): @@ -380,6 +392,25 @@ class FleetRegistry: return 200, {"ok": True} if path != "/v1/node/heartbeat": return 404, {"error": "Unknown operation"} + # Endpoint revisions prevent an old in-flight heartbeat or lost ack + # from reversing a migration. Use the actual listener, not a claimed IP. + from .recovery import tailnet_address + + revision = value.get("endpoint_revision", 0) + if type(revision) is not int or not 0 <= revision <= 1000000000: + return 400, {"error": "Invalid endpoint revision"} + previous_revision = row["binding"].get("endpoint_revision", 0) + if revision < previous_revision: + return 409, {"error": "Endpoint superseded"} + if revision > previous_revision: + if ( + not tailnet_address(address) + or value.get("core_endpoint") != f"https://{address}:8782" + ): + return 400, {"error": "Endpoint does not match authenticated channel"} + row["core_address"] = address + row["binding"]["endpoint"] = value["core_endpoint"] + row["binding"]["endpoint_revision"] = revision binding = ExecutionBinding.model_validate(value["execution_binding"]) if binding.node_id != node_id or binding.platform.value != "linux": return 400, {"error": "Invalid Node inventory"} @@ -417,4 +448,10 @@ class FleetRegistry: except (ValueError, TypeError, KeyError, sqlite3.Error): # Telemetry persistence failure must not block device control. pass - return 200, {"monitor_ack": monitor_ack, "ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response, **enrollment_response} + return 200, { + "monitor_ack": monitor_ack, + "ok": True, + "client_pem": row["binding"]["client_pem"], + **sensor_response, + **enrollment_response, + } diff --git a/src/k1link/fleet/sensors.py b/src/k1link/fleet/sensors.py index 2b56a8b..37cc3ab 100644 --- a/src/k1link/fleet/sensors.py +++ b/src/k1link/fleet/sensors.py @@ -20,12 +20,22 @@ ACTIONS = { "option", "offer", "close-peer", + "preview.start", + "preview.stop", + "record.start", + "record.stop", + "photo.capture", + "settings.read", + "settings.apply", + "files.list", } +MAX_INVENTORY_ITEMS = 500 +MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024 def validate_inventory(value, node_id): items = value.get("devices", []) - if not isinstance(items, list) or len(items) > 16: + if not isinstance(items, list) or len(items) > MAX_INVENTORY_ITEMS: raise ValueError("Invalid device inventory") seen = set() for item in items: @@ -38,7 +48,11 @@ def validate_inventory(value, node_id): raise ValueError("Invalid device execution binding") seen.add(item["id"]) state = value.get("sensor_state", {"items": items, "operations": [], "preparation": None}) - if state.get("items") != items or len(json.dumps(state)) > 262144: + if ( + not isinstance(state, dict) + or state.get("items") != items + or len(json.dumps(state, ensure_ascii=False).encode()) > MAX_SENSOR_STATE_BYTES + ): raise ValueError("Invalid sensor state") return state diff --git a/src/k1link/fleet/transport.py b/src/k1link/fleet/transport.py index ef0e4c0..ad51aa0 100644 --- a/src/k1link/fleet/transport.py +++ b/src/k1link/fleet/transport.py @@ -57,14 +57,19 @@ class NodeChannelHandler(BaseHTTPRequestHandler): if ( self.headers.get("Content-Type") != "application/json" or self.headers.get("Origin") - or not 0 < size <= 1048576 + # A bounded 500-camera summary is carried both in devices and + # sensor_state for the existing paired heartbeat contract. + or not 0 < size <= 8 * 1024 * 1024 ): raise ValueError value = json.loads(self.rfile.read(size)) if not isinstance(value, dict): raise ValueError status, data = self.server.registry.receive( - self.connection.getpeercert(binary_form=True), self.path, value + self.connection.getpeercert(binary_form=True), + self.path, + value, + address=self.server.address, ) except (ValueError, KeyError, TypeError): self.close_connection = True diff --git a/src/k1link/fleet/trust.py b/src/k1link/fleet/trust.py index 20a19f8..e8a695d 100644 --- a/src/k1link/fleet/trust.py +++ b/src/k1link/fleet/trust.py @@ -206,6 +206,20 @@ class CoreTrust: return context +def verify_node_certificate(raw: bytes, node_id: str) -> Ed25519PublicKey: + cert = x509.load_der_x509_certificate(raw) + key = cert.public_key() + now = datetime.now(UTC) + if ( + not isinstance(key, Ed25519PublicKey) + or public_id("node_", key) != node_id + or not cert.not_valid_before_utc <= now < cert.not_valid_after_utc + ): + raise PairingError("Идентичность БК не совпала с сохранённой.") + key.verify(cert.signature, cert.tbs_certificate_bytes) + return key + + def node_request(invitation: dict, path: str, payload: dict) -> tuple[dict, Ed25519PublicKey, str]: address, port = endpoint(invitation["endpoint"], 8781) # The one-use secret is sent only AFTER authenticating the Node identity. @@ -217,16 +231,9 @@ def node_request(invitation: dict, path: str, payload: dict) -> tuple[dict, Ed25 connection = http.client.HTTPSConnection(address, port, timeout=5, context=context) try: connection.connect() - cert = x509.load_der_x509_certificate(connection.sock.getpeercert(binary_form=True)) - key = cert.public_key() - now = datetime.now(UTC) - if ( - not isinstance(key, Ed25519PublicKey) - or public_id("node_", key) != invitation["node_id"] - or not cert.not_valid_before_utc <= now < cert.not_valid_after_utc - ): - raise PairingError("Идентичность БК не совпала с приглашением.") - key.verify(cert.signature, cert.tbs_certificate_bytes) + key = verify_node_certificate( + connection.sock.getpeercert(binary_form=True), invitation["node_id"] + ) local = connection.sock.getsockname()[0] if not private_address(local): raise PairingError("Core не получил частный обратный адрес.") diff --git a/tests/fleet/test_pairing.py b/tests/fleet/test_pairing.py index 93a7339..5e9ea29 100644 --- a/tests/fleet/test_pairing.py +++ b/tests/fleet/test_pairing.py @@ -85,6 +85,148 @@ def cert(row): ) +def test_migrated_heartbeat_preserves_identity_and_old_reply_cannot_reverse(setup): + fleet, _, _, _ = setup + public, _ = create(setup) + fleet.advance(public["id"]) + old = fleet.find(public["id"]) + payload = { + **heartbeat(old), + "core_endpoint": "https://100.64.20.5:8782", + "endpoint_revision": 1, + } + assert fleet.receive(cert(old), "/v1/node/heartbeat", payload, address="192.168.20.5")[0] == 400 + assert fleet.find(public["id"])["binding"] == old["binding"] + assert fleet.receive(cert(old), "/v1/node/heartbeat", payload, address="100.64.20.5")[0] == 200 + current = fleet.find(public["id"]) + assert current["core_address"] == "100.64.20.5" + assert current["binding"]["binding_id"] == old["binding"]["binding_id"] + assert current["binding"]["client_pem"] == old["binding"]["client_pem"] + assert ( + fleet.receive(cert(old), "/v1/node/heartbeat", heartbeat(old), address="192.168.20.5")[0] + == 409 + ) + assert fleet.find(public["id"])["binding"] == current["binding"] + fleet.revoke(public["id"]) + assert fleet.receive(cert(old), "/v1/node/heartbeat", payload, address="100.64.20.5")[0] == 410 + + +def test_recovery_only_uses_previously_reported_tailnet_addresses(): + from k1link.fleet.recovery import node_addresses + + assert node_addresses( + { + "inventory": { + "networks": [ + { + "up": True, + "addresses": ["192.168.20.4/24", "8.8.8.8", "100.64.20.4/32", "::1"], + }, + {"up": False, "addresses": ["100.64.1.1/32"]}, + ] + } + } + ) == ["100.64.20.4"] + + +def test_recovery_client_leaf_is_distinct_from_issuer_with_same_pinned_key(setup): + from cryptography.x509.oid import ExtendedKeyUsageOID + + from k1link.fleet.recovery import client_context + + fleet, _, _, _ = setup + client_context(fleet.trust) + leaf, root = x509.load_pem_x509_certificates((fleet.root / "recovery-client.pem").read_bytes()) + assert leaf.subject != root.subject + assert leaf.issuer == root.subject + assert leaf.public_key().public_bytes_raw() == root.public_key().public_bytes_raw() + assert public_id("core_", leaf.public_key()) == fleet.trust.core_id + assert ( + ExtendedKeyUsageOID.CLIENT_AUTH + in leaf.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value + ) + leaf.verify_directly_issued_by(root) + + +def test_recovery_pins_node_before_application_request(setup, monkeypatch): + from k1link.fleet import recovery + + fleet, _, _, _ = setup + public, _ = create(setup) + fleet.advance(public["id"]) + row = fleet.find(public["id"]) + row["inventory"] = {"networks": [{"up": True, "addresses": ["100.64.20.4/32"]}]} + fleet.save(row) + calls = [] + + class Connection: + sock = None + + def connect(self): + self.sock = self + + def getpeercert(self, **kwargs): + return b"not the saved Node certificate" + + def close(self): + calls.append("closed") + + monkeypatch.setattr(recovery.http.client, "HTTPSConnection", lambda *a, **kw: Connection()) + monkeypatch.setattr(recovery, "request", lambda *a: calls.append("request")) + with pytest.raises(ValueError): + recovery.recover(fleet, row, "100.64.20.4", None) + assert calls == ["closed"] + + +def test_recovery_rechecks_revocation_and_waits_for_heartbeat(setup, monkeypatch): + from k1link.fleet import recovery + + fleet, _, _, _ = setup + public, _ = create(setup) + fleet.advance(public["id"]) + row = fleet.find(public["id"]) + row["inventory"] = {"networks": [{"up": True, "addresses": ["100.64.20.4/32"]}]} + fleet.save(row) + calls = [] + + class Connection: + def connect(self): + self.sock = self + + def getpeercert(self, **kwargs): + return b"verified below by test adapter" + + def getsockname(self): + return ("100.64.20.5", 30000) + + def close(self): + pass + + monkeypatch.setattr(recovery.http.client, "HTTPSConnection", lambda *a, **kw: Connection()) + monkeypatch.setattr(recovery, "verify_node_certificate", lambda *a: None) + + def request(connection, path, body): + calls.append(path) + return { + "schema": recovery.SCHEMA, + "node_id": row["node_id"], + "core_id": fleet.trust.core_id, + "binding_id": row["binding"]["binding_id"], + "endpoint": row["binding"]["endpoint"], + "endpoint_revision": 0, + } + + monkeypatch.setattr(recovery, "request", request) + recovery.recover(fleet, row, "100.64.20.4", None) + assert calls == ["/v1/channel/inspect", "/v1/channel/migrate"] + assert fleet.find(public["id"])["binding"] == row["binding"] + assert fleet.public(fleet.find(public["id"]))["connectivity"] == "offline" + calls.clear() + fleet.revoke(public["id"]) + recovery.recover(fleet, row, "100.64.20.4", None) + assert calls == ["/v1/channel/inspect"] + + def test_preview_add_is_durable_and_idempotent(setup): fleet, invite, _, calls = setup public, preview = create(setup) @@ -386,3 +528,53 @@ def test_sensor_cannot_claim_another_board_identity(setup): value["devices"][0]["snapshot"]["context"]["execution"]["node_id"] = "node_wrong" with pytest.raises(ValueError): validate_inventory(value, row["node_id"]) + + +def test_uninitialized_x4_can_be_prepared_remotely_with_instance_progress(setup): + from k1link.fleet import sensors + + public, _ = create(setup) + fleet = setup[0] + row = fleet.find(public["id"]) + inventory = sensor_inventory(row) + item = inventory["devices"][0] + item.update(id="instax4_" + "a" * 32, kind="insta360.x4", prepared=False, configured=False) + item["snapshot"]["context"]["device"].update( + device_id=item["id"], + model={ + "plugin_id": "missioncore.insta360", + "plugin_version": "0.1.0", + "model_id": "insta360.x4", + }, + ) + assert fleet.receive(cert(row), "/v1/node/heartbeat", inventory)[0] == 200 + now = datetime.now(UTC) + command = { + "api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", + "kind": "OperationRequest", + "operation_id": "op_" + "b" * 32, + "idempotency_key": "op_" + "b" * 32, + "session": {"device_id": item["id"], "session_id": "sensor_test"}, + "requested_at": now.isoformat(), + "deadline_at": (now + timedelta(seconds=350)).isoformat(), + "action_id": "prepare", + "parameters": {}, + } + assert sensors.submit(fleet, row["id"], command)["state"] == "queued" + _, result = fleet.receive(cert(row), "/v1/node/heartbeat", inventory) + assert result["sensor_commands"] == [command] + progress = { + "operation_id": command["operation_id"], + "device_id": item["id"], + "model_id": "insta360.x4", + "phase": "verify", + "state": "running", + "steps": [{"id": "profile", "state": "complete"}, {"id": "verify", "state": "running"}], + } + inventory["sensor_state"]["preparations"] = [progress] + inventory["sensor_results"] = [ + {"command": command, "state": "running", "preparation": progress} + ] + assert fleet.receive(cert(row), "/v1/node/heartbeat", inventory)[0] == 200 + assert sensors.operation(fleet, row["id"], command["operation_id"])["preparation"] == progress + assert fleet.find(row["id"])["sensor_state"]["preparations"] == [progress] diff --git a/tests/test_insta360_control.py b/tests/test_insta360_control.py new file mode 100644 index 0000000..5713464 --- /dev/null +++ b/tests/test_insta360_control.py @@ -0,0 +1,213 @@ +"""Camera command boundaries with a synthetic camera; never load a vendor SDK.""" + +import importlib.util +import json +import sys +import threading +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] / "plugins/insta360-x4/runtime" +spec = importlib.util.spec_from_file_location("missioncore_insta360", ROOT / "__init__.py") +package = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = package +spec.loader.exec_module(package) +from missioncore_insta360 import identity, native, operations # noqa: E402 + +ID_A = "instax4_" + "a" * 32 +ID_B = "instax4_" + "b" * 32 + + +def command(action="record.start", *, device=ID_A, session="session_test_a", params=None): + now = datetime.now(UTC) + op = "op_" + uuid.uuid4().hex + return { + "api_version": "missioncore.nodedc/plugin-sdk/v0alpha2", + "kind": "OperationRequest", + "operation_id": op, + "idempotency_key": op, + "session": {"session_id": session, "device_id": device}, + "action_id": action, + "requested_at": now.isoformat(), + "deadline_at": (now + timedelta(seconds=60)).isoformat(), + "parameters": params or {}, + } + + +class Camera: + def __init__(self): + self.calls = [] + self.answer = {"state": "complete", "result": {"recording": True}} + self.hook = None + + def call(self, action, params): + self.calls.append((action, params)) + if self.hook: + self.hook() + return self.answer + + +def test_concurrent_remote_and_local_retry_dispatches_once(tmp_path): + camera = Camera() + executor = operations.Operations(ID_A, "session_test_a", tmp_path, camera) + request = command() + entered, release = threading.Event(), threading.Event() + + def inspect_receipt(): + receipt = json.loads((tmp_path / (request["operation_id"] + ".json")).read_text()) + assert receipt["result"]["state"] == "unknown" + entered.set() + assert release.wait(3) + + camera.hook = inspect_receipt + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(executor.execute, request) + assert entered.wait(3) + second = pool.submit(executor.execute, request) + release.set() + assert first.result() == second.result() == camera.answer + assert len(camera.calls) == 1 + + +def test_crash_receipt_survives_restart_without_replaying_recording(tmp_path): + camera = Camera() + + def crash(): + raise SystemExit("synthetic crash after dispatch") + + camera.hook = crash + request = command() + with pytest.raises(SystemExit): + operations.Operations(ID_A, "session_test_a", tmp_path, camera).execute(request) + fresh = Camera() + recovered = operations.Operations(ID_A, "session_after_restart", tmp_path, fresh) + assert recovered.execute(request)["state"] == "unknown" + assert not fresh.calls + + +def test_camera_binding_session_deadline_and_parameters_precede_effects(tmp_path): + camera = Camera() + executor = operations.Operations(ID_A, "session_test_a", tmp_path, camera) + wrong = [ + command(device=ID_B), + command(session="session_old"), + command(params={"force": True}), + command("settings.apply", params={"mode": 7, "key": "library_path", "value": 1}), + command("settings.apply", params={"mode": 7, "key": "iso", "value": True}), + ] + expired = command() + expired["requested_at"] = (datetime.now(UTC) - timedelta(minutes=2)).isoformat() + expired["deadline_at"] = (datetime.now(UTC) - timedelta(minutes=1)).isoformat() + wrong.append(expired) + for request in wrong: + with pytest.raises(ValueError): + executor.execute(request) + assert not camera.calls + assert not list(tmp_path.iterdir()) + + +def test_operation_identity_cannot_be_reused_for_another_effect(tmp_path): + camera = Camera() + executor = operations.Operations(ID_A, "session_test_a", tmp_path, camera) + request = command() + executor.execute(request) + request["action_id"] = "record.stop" + with pytest.raises(ValueError, match="already in use"): + executor.execute(request) + assert len(camera.calls) == 1 + + +def test_two_instances_keep_receipts_and_recording_independent(tmp_path): + a, b = Camera(), Camera() + first = operations.Operations(ID_A, "session_test_a", tmp_path / "a", a) + second = operations.Operations(ID_B, "session_test_b", tmp_path / "b", b) + first.execute(command()) + second.execute(command("preview.stop", device=ID_B, session="session_test_b")) + assert [action for action, _ in a.calls] == ["record.start"] + assert [action for action, _ in b.calls] == ["preview.stop"] + + +def test_setting_acknowledgement_requires_camera_readback(tmp_path): + camera = Camera() + camera.answer = {"state": "complete", "result": {"values": {"white_balance": 0}}} + executor = operations.Operations(ID_A, "session_test_a", tmp_path, camera) + request = command("settings.apply", params={"mode": 7, "key": "white_balance", "value": 5000}) + assert executor.execute(request)["state"] == "unknown" + camera.answer = {"state": "complete", "result": {"values": {"white_balance": 5000}}} + # The old command remains uncertain. Only a new explicit command may run. + assert executor.execute(request)["state"] == "unknown" + assert len(camera.calls) == 1 + assert ( + executor.execute(command("settings.apply", params=request["parameters"]))["state"] + == "complete" + ) + + +def test_failed_journal_prevents_sdk_dispatch(tmp_path, monkeypatch): + camera = Camera() + + def full(*_): + raise OSError("synthetic disk full") + + monkeypatch.setattr(operations, "atomic", full) + with pytest.raises(OSError): + operations.Operations(ID_A, "session_test_a", tmp_path, camera).execute(command()) + assert not camera.calls + + +def test_model_binding_uses_product_and_tracks_usb_replug(tmp_path): + usb = tmp_path / "1-2.3" + usb.mkdir() + values = { + "idVendor": "2e1a", + "idProduct": "0002", + "product": "Insta360 X4", + "serial": "SYNTHETIC-X4-A", + "busnum": "1", + "devnum": "7", + } + for key, value in values.items(): + (usb / key).write_text(value) + first = identity.read_binding("1-2.3", tmp_path) + (usb / "devnum").write_text("8") + second = identity.read_binding("1-2.3", tmp_path) + assert first.device_id == second.device_id and first != second + assert second.device_path == "/dev/bus/usb/001/008" + (usb / "product").write_text("Insta360 ONE R") + with pytest.raises(ValueError, match="model"): + identity.read_binding("1-2.3", tmp_path) + with pytest.raises(ValueError, match="binding"): + identity.read_binding("../1-2.3", tmp_path) + + +def test_sdk_library_is_not_loaded_before_isolation_check(monkeypatch): + calls = [] + + def reject(_): + raise RuntimeError("synthetic namespace refusal") + + monkeypatch.setattr(native, "verify_isolation", reject) + monkeypatch.setattr(native.ctypes, "CDLL", lambda value: calls.append(value)) + with pytest.raises(RuntimeError, match="namespace"): + native.NativeCamera(object(), "/unused/library.so", "/unused/logs") + assert not calls + + +def test_video_queue_can_drain_while_a_camera_command_is_blocked(): + # Construct only the Python transport with a fake C ABI. No isolation + # override or vendor library is used by the real NativeCamera constructor. + camera = native.NativeCamera.__new__(native.NativeCamera) + camera.lock, camera.video_lock = threading.RLock(), threading.Lock() + camera.handle, camera.buffer = 1, object() + + class API: + def mc_x4_read_video(self, *_): + return 0 + + camera.api = API() + with ThreadPoolExecutor(max_workers=1) as pool, camera.lock: + assert pool.submit(camera.video).result(timeout=2) is None diff --git a/tests/test_insta360_native.py b/tests/test_insta360_native.py new file mode 100644 index 0000000..9abec28 --- /dev/null +++ b/tests/test_insta360_native.py @@ -0,0 +1,12 @@ +"""Collect the same native checks shipped in the Ubuntu build artifact.""" + +import importlib.util +import sys +from pathlib import Path + +path = Path(__file__).resolve().parents[1] / "plugins/insta360-x4/native/tests/check_abi.py" +spec = importlib.util.spec_from_file_location("missioncore_x4_abi_tests", path) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +TestNativeBridge = module.TestNativeBridge diff --git a/tests/test_insta360_sdk_source.py b/tests/test_insta360_sdk_source.py new file mode 100644 index 0000000..11899b8 --- /dev/null +++ b/tests/test_insta360_sdk_source.py @@ -0,0 +1,91 @@ +"""The build must fail before using corrupt, wrong-platform or partial SDK bytes.""" + +import hashlib +import importlib.util +import json +import struct +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "x4_sdk_source", ROOT / "plugins/insta360-x4/packaging/fetch_sdk.py" +) +sdk = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(sdk) + + +def elf(machine=62): + strings = b"\0libc.so.6\0" + table_end = 64 + 3 * 64 + data = bytearray(table_end) + data[:7] = b"\x7fELF\x02\x01\x01" + struct.pack_into("