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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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<string | null>(() =>
|
||||
restorePersistedDeviceModelId(registry, selectionStorage)
|
||||
|
||||
@@ -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<string>();
|
||||
const modelIds = new Set<string>();
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins}>
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins} nodeSensorContributions={installedNodeSensorContributions}>
|
||||
<ComputeContourProvider>
|
||||
<App />
|
||||
</ComputeContourProvider>
|
||||
|
||||
@@ -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<SensorTransport>(()=>({
|
||||
enrollment:{
|
||||
state:()=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/enrollment`),
|
||||
|
||||
@@ -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" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
|
||||
</SettingsCard>}
|
||||
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
|
||||
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source=readFileSync(new URL('../../../plugins/insta360-x4/frontend/src/model.ts',import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const {cameraLabel,cameraStatus,resolutionLabel}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const camera={id:'instax4_synthetic_a',online:true,configured:true,prepared:true,snapshot:{enrollment:'enrolled'},camera_status:{connected:true,recording:0,preview:0,function_mode:7}};
|
||||
|
||||
test('SD recording remains visible after preview closes, independently for each camera',()=>{
|
||||
const recording={...camera,camera_status:{...camera.camera_status,recording:1}};
|
||||
assert.equal(cameraLabel(recording,true).label,'Запись на карту камеры');
|
||||
assert.equal(cameraLabel({...camera,id:'instax4_synthetic_b'},true).label,'Подключена');
|
||||
assert.equal(cameraStatus(recording).preview,0);
|
||||
});
|
||||
test('stale and unconfirmed camera state never appears ready or actively recording',()=>{
|
||||
assert.equal(cameraLabel({...camera,camera_status:{...camera.camera_status,recording:1}},false).tone,'neutral');
|
||||
assert.equal(cameraLabel({...camera,camera_status:{}},true).tone,'warning');
|
||||
assert.equal(cameraLabel({...camera,camera_status:{...camera.camera_status,preview:-1}},true).tone,'warning');
|
||||
assert.equal(cameraStatus({...camera,camera_status:{preview:true,recording:'1',connected:1}}).preview,null);
|
||||
});
|
||||
test('installed model support does not enroll a new physical camera',()=>{
|
||||
assert.equal(cameraLabel({...camera,configured:false},true).label,'Требуется подготовка');
|
||||
assert.equal(cameraLabel({...camera,initializable:false},true).tone,'warning');
|
||||
});
|
||||
test('SDK resolution names are presented with their actual dimensions and frame rate',()=>{
|
||||
assert.equal(resolutionLabel('3840_1920_30'),'3840 × 1920 · 30 кадр/с');
|
||||
});
|
||||
@@ -21,7 +21,9 @@ const render=(value,enabled=true)=>renderToStaticMarkup(createElement(Detail,{de
|
||||
test('manual K1 surface admits one START only after current control proof',()=>{
|
||||
const markup=render(device);
|
||||
assert.match(markup,/Инициировать запуск/);
|
||||
assert.match(markup,/<button[^>]*disabled=""[^>]*>Инициировать запуск<\/button>/);
|
||||
const start=(markup.match(/<button\b[\s\S]*?<\/button>/g)??[]).filter(button=>button.replace(/<[^>]+>/g,'')==='Инициировать запуск');
|
||||
assert.equal(start.length,1);
|
||||
assert.match(start[0],/<button[^>]*disabled=""/);
|
||||
for(const label of ['Тип установки / носитель','Режим GNSS','Название проекта'])assert.ok(markup.includes(label));
|
||||
assert.match(markup,/Настройки устройства/);
|
||||
assert.doesNotMatch(markup,/Остановить устройство|Обновить просмотр|sensor-live-layout/);
|
||||
@@ -54,7 +56,10 @@ test('active acquisition exposes STOP and Rerun without another START',()=>{
|
||||
test('calibration loader stays inside the primary action',()=>{
|
||||
const starting={...device,control:{...device.control,can_start:false},snapshot:{...device.snapshot,acquisition:'starting'}};
|
||||
const markup=render(starting);
|
||||
assert.match(markup,/<button[^>]*aria-busy="true"[\s\S]*?nodedc-activity-indicator[\s\S]*?Запускаем устройство<\/button>/);
|
||||
const start=(markup.match(/<button\b[\s\S]*?<\/button>/g)??[]).filter(button=>button.replace(/<[^>]+>/g,'')==='Запускаем устройство');
|
||||
assert.equal(start.length,1);
|
||||
assert.match(start[0],/<button[^>]*aria-busy="true"/);
|
||||
assert.equal((start[0].match(/class="nodedc-activity-indicator"/g)??[]).length,1);
|
||||
assert.match(markup,/K1 калибруется и готовит облако точек/);
|
||||
assert.doesNotMatch(markup,/Обновить просмотр/);
|
||||
});
|
||||
|
||||
@@ -2856,8 +2856,9 @@ test("compact connection error can expose only its contextual recovery path", ()
|
||||
}));
|
||||
|
||||
assert.match(markup, /Подключение не завершено/);
|
||||
assert.match(markup, />Подключить новый K1<\/button>/);
|
||||
assert.doesNotMatch(markup, /Проверить состояние|>Закрыть<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 1);
|
||||
assert.doesNotMatch(markup, /Проверить состояние/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Закрыть").length, 0);
|
||||
});
|
||||
|
||||
test("connection attempt network phase copy distinguishes absent, applied and unknown outcomes", () => {
|
||||
@@ -4356,16 +4357,16 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
|
||||
assert.match(markup, /Повторить поиск Bluetooth/);
|
||||
assert.doesNotMatch(markup, /old-stop-operation/);
|
||||
const priorAction = deviceRowActionButton(markup, "ble-k1-001");
|
||||
assert.match(priorAction, />Выбрать<\/button>/);
|
||||
assert.equal(priorAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(priorAction, /\bdisabled(?:=|\s|>)/);
|
||||
const replacementAction = deviceRowActionButton(markup, "ble-k1-new");
|
||||
assert.match(replacementAction, />Выбрать<\/button>/);
|
||||
assert.equal(replacementAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(replacementAction, /\bdisabled(?:=|\s|>)/);
|
||||
assert.equal(
|
||||
buttonMarkupWithText(markup, "Подключить новый K1").length,
|
||||
0,
|
||||
);
|
||||
assert.doesNotMatch(markup, /<button[^>]*disabled[^>]*>Недоступно<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Недоступно").filter(button => /<button[^>]*disabled/.test(button)).length, 0);
|
||||
assert.doesNotMatch(
|
||||
markup,
|
||||
/Попытка настройки завершена|Проверить подключение без изменения сети|Управление не подтверждено/,
|
||||
@@ -4388,7 +4389,7 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
|
||||
/Подходящих K1 не найдено\. Повторите поиск\./,
|
||||
);
|
||||
const onlyPriorAction = deviceRowActionButton(onlyPriorMarkup, "ble-k1-001");
|
||||
assert.match(onlyPriorAction, />Выбрать<\/button>/);
|
||||
assert.equal(onlyPriorAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(onlyPriorAction, /\bdisabled(?:=|\s|>)/);
|
||||
assert.match(onlyPriorMarkup, /Результатов: 1/);
|
||||
assert.doesNotMatch(onlyPriorMarkup, />Недоступно|Переподключиться/);
|
||||
@@ -4415,7 +4416,7 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
|
||||
historicalAuditMarkup,
|
||||
"ble-k1-001",
|
||||
);
|
||||
assert.match(historicallyRetiredAction, />Выбрать<\/button>/);
|
||||
assert.equal(historicallyRetiredAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(
|
||||
historicallyRetiredAction,
|
||||
/\bdisabled(?:=|\s|>)/,
|
||||
@@ -4505,7 +4506,7 @@ test("an exact prior CoreBluetooth UUID remains a normal fresh selection across
|
||||
markup,
|
||||
"f89438fa-55ed-85ad-eed7-734ac84746d8",
|
||||
);
|
||||
assert.match(priorDeviceAction, />Выбрать<\/button>/);
|
||||
assert.equal(priorDeviceAction.replace(/<[^>]+>/g, ""), "Выбрать");
|
||||
assert.doesNotMatch(priorDeviceAction, /\bdisabled(?:=|\s|>)/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
|
||||
assertCanonicalConnectionCopy(markup);
|
||||
@@ -6112,7 +6113,7 @@ test("an explicit network action keeps one disabled Step-2 form with an in-place
|
||||
assert.match(markup, /Пароль передан/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
assert.equal((markup.match(/<input[^>]*disabled=""/g) ?? []).length, 2);
|
||||
assert.doesNotMatch(markup, />Применить<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Применить").length, 0);
|
||||
assertCanonicalConnectionCopy(markup);
|
||||
});
|
||||
|
||||
@@ -6297,8 +6298,8 @@ test("current Bridge bootstrap stays visible over an older unresolved STOP check
|
||||
assert.match(markup, /<span>02<\/span>/);
|
||||
assert.match(markup, /nodedc-activity-indicator/);
|
||||
assert.doesNotMatch(markup, /Прежнее подключение не подтверждено/);
|
||||
assert.doesNotMatch(markup, />Переподключиться<\/button>/);
|
||||
assert.doesNotMatch(markup, />Подключить новый K1<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
|
||||
assert.equal(buttonMarkupWithText(markup, "Подключить новый K1").length, 0);
|
||||
|
||||
assert.equal(isConnectionControlBootstrapSettling({
|
||||
...state,
|
||||
@@ -6454,7 +6455,8 @@ test("correlated terminal recovery resets before exposing a separate clean Scan"
|
||||
buttonMarkupWithText(markup, "Проверить прежнее подключение").length,
|
||||
0,
|
||||
);
|
||||
assert.doesNotMatch(markup, /Проверить состояние|>Закрыть<\/button>/);
|
||||
assert.doesNotMatch(markup, /Проверить состояние/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Закрыть").length, 0);
|
||||
assert.doesNotMatch(markup, /Выбрать другое|Пароль Wi‑Fi|Пароль передан|>Применить</);
|
||||
assert.doesNotMatch(markup, /<span>02<\/span>|private transport exception/);
|
||||
|
||||
@@ -7138,7 +7140,7 @@ test("a stale reopen settlement cannot own the current wizard surface", () => {
|
||||
assert.match(markup, /<span>01<\/span>/);
|
||||
assert.doesNotMatch(markup, /Подключение…|aria-busy="true"/);
|
||||
assert.doesNotMatch(markup, /<span>02<\/span>|<span>03<\/span>/);
|
||||
assert.match(markup, />Найти по Bluetooth<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Найти по Bluetooth").length, 1);
|
||||
const modeToggle = markup.match(
|
||||
/<button[^>]*aria-label="Способ подключения"[^>]*>/,
|
||||
)?.[0];
|
||||
@@ -7258,7 +7260,7 @@ test("stale raw Bluetooth results stay hidden until an explicit search", () => {
|
||||
|
||||
assert.match(markup, /<span>01<\/span>/);
|
||||
assert.doesNotMatch(markup, /Replacement K1/);
|
||||
assert.match(markup, />Найти по Bluetooth<\/button>/);
|
||||
assert.equal(buttonMarkupWithText(markup, "Найти по Bluetooth").length, 1);
|
||||
assert.doesNotMatch(markup, /aria-busy="true"/);
|
||||
assert.doesNotMatch(markup, /<span>02<\/span>|<span>03<\/span>/);
|
||||
assert.doesNotMatch(markup, /Завершаем предыдущее действие|Завершаем ранее начатое действие/);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source=readFileSync(new URL('../../../packages/sensor-ui/src/preparation.ts',import.meta.url),'utf8');
|
||||
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
|
||||
const {devicePreparation,preparationProgress}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
|
||||
const device={id:'instax4_a'};
|
||||
const operation={operation_id:'op_a',device_id:device.id,action_id:'prepare',state:'running',requested_at:'2026-09-08T12:00:00Z'};
|
||||
const preparation={operation_id:'op_a',device_id:device.id,model_id:'insta360.x4',state:'running',phase:'verify',steps:[{id:'profile',label:'Драйвер',state:'complete'},{id:'verify',label:'Выбранная камера',state:'running'}]};
|
||||
|
||||
test('progress belongs to the exact operation and physical camera in either UI',()=>{
|
||||
const inventory={items:[device],operations:[operation],preparations:[{...preparation,device_id:'instax4_b'},preparation]};
|
||||
assert.deepEqual(devicePreparation(inventory,device),preparation);
|
||||
assert.equal(devicePreparation({...inventory,preparations:[{...preparation,operation_id:'op_old'}]},device),undefined);
|
||||
assert.equal(devicePreparation({...inventory,preparations:[preparation,preparation]},device),undefined);
|
||||
});
|
||||
test('a shared installation success still leaves instance verification outstanding',()=>{
|
||||
assert.deepEqual(preparationProgress(preparation,'Подготовка'),{label:'Подготовка',value:.5,valueText:'Выбранная камера'});
|
||||
assert.equal(preparationProgress(undefined,'Команда').value,undefined);
|
||||
});
|
||||
test('legacy RealSense progress cannot appear on an Insta360 row',()=>{
|
||||
const legacy={items:[device],operations:[operation],preparation:{state:'complete',started_at:Date.parse(operation.requested_at)/1000,steps:[{id:'service',label:'Служба',state:'complete'}]}};
|
||||
assert.equal(devicePreparation(legacy,device),undefined);
|
||||
const rs={id:'rsd455_a'};
|
||||
const value=devicePreparation({...legacy,operations:[{...operation,device_id:rs.id}]},rs);
|
||||
assert.equal(value.state,'running');
|
||||
assert.equal(value.steps.at(-1).state,'running');
|
||||
});
|
||||
test('new Nodes do not fall back to stale global preparation reports',()=>{
|
||||
const rs={id:'rsd455_a'};
|
||||
assert.equal(devicePreparation({items:[rs],operations:[{...operation,device_id:rs.id}],preparations:[],preparation:{started_at:Date.now()/1000,state:'complete',steps:[]}},rs),undefined);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -21,15 +22,16 @@ type Invitation struct {
|
||||
SecretHash string `json:"secret_hash,omitempty"`
|
||||
}
|
||||
type CoreBinding struct {
|
||||
BindingID string `json:"binding_id"`
|
||||
CoreID string `json:"core_id"`
|
||||
CoreName string `json:"core_name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CAPEM string `json:"ca_pem"`
|
||||
ClientPEM string `json:"client_pem"`
|
||||
Receipt string `json:"receipt,omitempty"`
|
||||
OfferHash string `json:"offer_hash,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
EndpointRevision uint64 `json:"endpoint_revision,omitempty"`
|
||||
BindingID string `json:"binding_id"`
|
||||
CoreID string `json:"core_id"`
|
||||
CoreName string `json:"core_name"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
CAPEM string `json:"ca_pem"`
|
||||
ClientPEM string `json:"client_pem"`
|
||||
Receipt string `json:"receipt,omitempty"`
|
||||
OfferHash string `json:"offer_hash,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
type PairState struct {
|
||||
Schema string `json:"schema"`
|
||||
@@ -171,6 +173,12 @@ func (p *Pairing) addresses() []string {
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
if tailnetAddress(result[i]) != tailnetAddress(result[j]) {
|
||||
return tailnetAddress(result[i])
|
||||
}
|
||||
return result[i] < result[j]
|
||||
})
|
||||
return result
|
||||
}
|
||||
func (p *Pairing) invite(address string) (map[string]any, error) {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
const recoverySchema = "missioncore.node-channel-recovery/v1"
|
||||
|
||||
// Address preference is transport policy, never proof of peer identity.
|
||||
func tailnetAddress(address string) bool {
|
||||
ip := net.ParseIP(address).To4()
|
||||
return ip != nil && ip[0] == 100 && ip[1] >= 64 && ip[1] <= 127
|
||||
}
|
||||
|
||||
func recoveryTLS(b CoreBinding, key ed25519.PrivateKey, address string) (*tls.Config, error) {
|
||||
config, err := bindingTLS(b, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cert, err := bootstrapCertificate(key, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert},
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: config.RootCAs,
|
||||
VerifyConnection: func(state tls.ConnectionState) error {
|
||||
if !recoveryPeer(state, b.CoreID) {
|
||||
return errors.New("recovery requires the bound Core identity")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func recoveryPeer(state tls.ConnectionState, coreID string) bool {
|
||||
if len(state.VerifiedChains) == 0 || len(state.PeerCertificates) == 0 {
|
||||
return false
|
||||
}
|
||||
key, ok := state.PeerCertificates[0].PublicKey.(ed25519.PublicKey)
|
||||
return ok && keyID("core_", key) == coreID
|
||||
}
|
||||
|
||||
func (p *Pairing) recoveryHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil || !tailnetAddress(host) || r.TLS == nil {
|
||||
http.Error(w, "Private authenticated channel required", 403)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Schema string `json:"schema"`
|
||||
BindingID string `json:"binding_id"`
|
||||
ExpectedEndpoint string `json:"expected_endpoint,omitempty"`
|
||||
ExpectedRevision uint64 `json:"expected_revision,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
}
|
||||
if !pairDecode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
b := p.state.Binding
|
||||
// Recheck durable authority on every request, including existing TLS sessions.
|
||||
if p.state.Phase != "paired" || b == nil || !recoveryPeer(*r.TLS, b.CoreID) || body.BindingID != b.BindingID {
|
||||
http.Error(w, "Binding unavailable", 403)
|
||||
return
|
||||
}
|
||||
if body.Schema != recoverySchema {
|
||||
http.Error(w, "Incompatible recovery protocol", 400)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v1/channel/inspect":
|
||||
case "/v1/channel/migrate":
|
||||
u, err := url.Parse(body.Endpoint)
|
||||
if err != nil || !privateEndpoint(body.Endpoint, CorePort) || !tailnetAddress(u.Hostname()) || u.Hostname() != host {
|
||||
http.Error(w, "Endpoint must address the authenticated Core transport", 400)
|
||||
return
|
||||
}
|
||||
if body.ExpectedEndpoint != b.Endpoint || body.ExpectedRevision != b.EndpointRevision || b.EndpointRevision >= 1000000000 {
|
||||
http.Error(w, "Endpoint changed; inspect again", 409)
|
||||
return
|
||||
}
|
||||
if body.Endpoint != b.Endpoint {
|
||||
next := p.state
|
||||
copy := *b
|
||||
copy.Endpoint = body.Endpoint
|
||||
copy.EndpointRevision++
|
||||
next.Binding = ©
|
||||
if p.save(next) != nil {
|
||||
http.Error(w, "State unavailable", 503)
|
||||
return
|
||||
}
|
||||
p.connection = "offline"
|
||||
p.lastSeen = 0
|
||||
b = p.state.Binding
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id, _ := p.store.Public()
|
||||
reply(w, 200, map[string]any{"schema": recoverySchema, "node_id": id, "core_id": b.CoreID, "binding_id": b.BindingID, "endpoint": b.Endpoint, "endpoint_revision": b.EndpointRevision})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func recoveryFixture(t *testing.T) (*Pairing, tls.Certificate) {
|
||||
t.Helper()
|
||||
p, _ := testPairing(t)
|
||||
pub, key, _ := ed25519.GenerateKey(rand.Reader)
|
||||
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "synthetic Core"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature}
|
||||
der, err := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca, _ = x509.ParseCertificate(der)
|
||||
leaf := func(serial int64, public any) []byte {
|
||||
spec := &x509.Certificate{SerialNumber: big.NewInt(serial), NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, KeyUsage: x509.KeyUsageDigitalSignature}
|
||||
value, e := x509.CreateCertificate(rand.Reader, spec, ca, public, key)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return value
|
||||
}
|
||||
b := CoreBinding{BindingID: token(), CoreID: keyID("core_", pub), CoreName: "Test", Endpoint: "https://192.168.10.5:8782", CAPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})), ClientPEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leaf(2, p.store.pairingKey().Public())}))}
|
||||
if err := p.save(PairState{Schema: PairSchema, Phase: "paired", Binding: &b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p, tls.Certificate{Certificate: [][]byte{leaf(3, pub)}, PrivateKey: key}
|
||||
}
|
||||
|
||||
func recoveryCall(p *Pairing, client tls.Certificate, path string, body map[string]any) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader(string(raw)))
|
||||
r.RemoteAddr = "100.64.10.5:42000"
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
cert, _ := x509.ParseCertificate(client.Certificate[0])
|
||||
r.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{cert}}, PeerCertificates: []*x509.Certificate{cert}}
|
||||
w := httptest.NewRecorder()
|
||||
p.recoveryHandler().ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestRecoveryMigrationPreservesTrustAndSurvivesRestart(t *testing.T) {
|
||||
p, client := recoveryFixture(t)
|
||||
old := *p.state.Binding
|
||||
body := map[string]any{"schema": recoverySchema, "binding_id": old.BindingID, "expected_endpoint": old.Endpoint, "expected_revision": 0, "endpoint": "https://100.64.10.5:8782"}
|
||||
if out := recoveryCall(p, client, "/v1/channel/migrate", body); out.Code != 200 {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
got := *p.state.Binding
|
||||
if got.BindingID != old.BindingID || got.CoreID != old.CoreID || got.CAPEM != old.CAPEM || got.ClientPEM != old.ClientPEM || got.EndpointRevision != 1 {
|
||||
t.Fatal("migration replaced authority")
|
||||
}
|
||||
if out := recoveryCall(p, client, "/v1/channel/migrate", body); out.Code != 409 {
|
||||
t.Fatal("stale request accepted")
|
||||
}
|
||||
reopened, err := OpenPairing(p.store, filepath.Dir(p.path), "test", p.inventory)
|
||||
if err != nil || reopened.state.Binding.Endpoint != got.Endpoint || reopened.state.Binding.EndpointRevision != 1 {
|
||||
t.Fatal("migration not durable", err)
|
||||
}
|
||||
inspect := map[string]any{"schema": recoverySchema, "binding_id": old.BindingID}
|
||||
out := recoveryCall(reopened, client, "/v1/channel/inspect", inspect)
|
||||
if out.Code != 200 || !strings.Contains(out.Body.String(), got.Endpoint) || strings.Contains(out.Body.String(), "PEM") {
|
||||
t.Fatal("lost ack not recoverable")
|
||||
}
|
||||
if err := reopened.cancel(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recoveryCall(reopened, client, "/v1/channel/inspect", inspect).Code != 403 {
|
||||
t.Fatal("revocation bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryRejectsForeignAuthorityAndWrongDestination(t *testing.T) {
|
||||
p, client := recoveryFixture(t)
|
||||
b := *p.state.Binding
|
||||
body := map[string]any{"schema": recoverySchema, "binding_id": b.BindingID, "expected_endpoint": b.Endpoint, "endpoint": "https://100.64.10.5:8782"}
|
||||
_, foreign := recoveryFixture(t)
|
||||
if recoveryCall(p, foreign, "/v1/channel/migrate", body).Code != 403 {
|
||||
t.Fatal("foreign Core accepted")
|
||||
}
|
||||
for _, destination := range []string{"https://100.64.10.6:8782", "https://8.8.8.8:8782", "https://192.168.10.5:8782", "https://100.64.10.5:443", "https://100.64.10.5:8782/path"} {
|
||||
body["endpoint"] = destination
|
||||
if recoveryCall(p, client, "/v1/channel/migrate", body).Code != 400 {
|
||||
t.Fatal("wrong destination accepted", destination)
|
||||
}
|
||||
}
|
||||
if *p.state.Binding != b {
|
||||
t.Fatal("rejection mutated binding")
|
||||
}
|
||||
// A valid Node credential signed by this same CA is not Core authority.
|
||||
block, _ := pem.Decode([]byte(b.ClientPEM))
|
||||
otherNode := tls.Certificate{Certificate: [][]byte{block.Bytes}, PrivateKey: p.store.pairingKey()}
|
||||
if recoveryCall(p, otherNode, "/v1/channel/inspect", body).Code != 403 {
|
||||
t.Fatal("Node impersonated Core")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryTLSRequiresBoundCoreCertificate(t *testing.T) {
|
||||
p, core := recoveryFixture(t)
|
||||
config, err := recoveryTLS(*p.state.Binding, p.store.pairingKey(), "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
|
||||
server.Config.ErrorLog = log.New(io.Discard, "", 0)
|
||||
server.TLS = config
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
_, foreign := recoveryFixture(t)
|
||||
block, _ := pem.Decode([]byte(p.state.Binding.ClientPEM))
|
||||
node := tls.Certificate{Certificate: [][]byte{block.Bytes}, PrivateKey: p.store.pairingKey()}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
certificates []tls.Certificate
|
||||
accepted bool
|
||||
}{
|
||||
{"Core", []tls.Certificate{core}, true}, {"missing", nil, false}, {"foreign", []tls.Certificate{foreign}, false}, {"Node", []tls.Certificate{node}, false},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13, InsecureSkipVerify: true, Certificates: item.certificates}}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
|
||||
response, e := client.Get(server.URL)
|
||||
if e == nil {
|
||||
response.Body.Close()
|
||||
}
|
||||
if (e == nil) != item.accepted {
|
||||
t.Fatal("TLS admission", e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailnetIsFirstInvitationAddress(t *testing.T) {
|
||||
p, _ := testPairing(t)
|
||||
p.inventory = func() Inventory {
|
||||
return Inventory{Networks: []Network{
|
||||
{Up: true, Addresses: []string{"192.168.10.4/24", "100.64.10.4/32"}},
|
||||
{Up: false, Addresses: []string{"100.64.1.1/32"}},
|
||||
}}
|
||||
}
|
||||
addresses := p.addresses()
|
||||
if len(addresses) != 2 || addresses[0] != "100.64.10.4" {
|
||||
t.Fatal(addresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldHeartbeatCannotRevokeMigratedBinding(t *testing.T) {
|
||||
p, core := recoveryFixture(t)
|
||||
old := *p.state.Binding
|
||||
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
p.clients[old.BindingID+old.Endpoint+digest(old.ClientPEM)] = &http.Client{
|
||||
Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return &http.Response{StatusCode: 410, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil
|
||||
}),
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() { defer close(done); p.channel(ctx) }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("heartbeat did not start")
|
||||
}
|
||||
body := map[string]any{"schema": recoverySchema, "binding_id": old.BindingID, "expected_endpoint": old.Endpoint, "endpoint": "https://100.64.10.5:8782"}
|
||||
response := recoveryCall(p, core, "/v1/channel/migrate", body)
|
||||
cancel()
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("heartbeat did not stop")
|
||||
}
|
||||
if response.Code != 200 || p.state.Phase != "paired" || p.state.Binding.EndpointRevision != 1 || p.connection != "offline" {
|
||||
t.Fatal("old heartbeat changed new binding")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (p *Pairing) remoteHandler() http.Handler {
|
||||
return
|
||||
}
|
||||
b := *body.Binding
|
||||
if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 {
|
||||
if len(b.BindingID) != 43 || len(b.CoreName) < 1 || len(b.CoreName) > 128 || len(b.CoreID) != 69 || len(b.CAPEM) > 8192 || len(b.ClientPEM) > 8192 || b.Receipt != "" || b.OfferHash != "" || b.ExpiresAt != 0 || b.EndpointRevision != 0 {
|
||||
http.Error(w, "Invalid binding", 400)
|
||||
return
|
||||
}
|
||||
@@ -154,6 +154,8 @@ func (p *Pairing) Run(ctx context.Context) {
|
||||
go p.channel(ctx)
|
||||
var server *http.Server
|
||||
endpoint := ""
|
||||
serverIdentity := ""
|
||||
var refreshAt time.Time
|
||||
closeServer := func() {
|
||||
if server != nil {
|
||||
timeout, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
@@ -162,6 +164,7 @@ func (p *Pairing) Run(ctx context.Context) {
|
||||
server = nil
|
||||
}
|
||||
endpoint = ""
|
||||
serverIdentity = ""
|
||||
}
|
||||
defer closeServer()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
@@ -170,29 +173,50 @@ func (p *Pairing) Run(ctx context.Context) {
|
||||
p.mu.Lock()
|
||||
_ = p.expire()
|
||||
desired := ""
|
||||
var recovery *CoreBinding
|
||||
identity := "bootstrap"
|
||||
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation != nil {
|
||||
desired = p.state.Invitation.Endpoint
|
||||
}
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil {
|
||||
for _, address := range p.addresses() {
|
||||
if tailnetAddress(address) {
|
||||
desired = "https://" + address + ":" + PairPort
|
||||
copy := *p.state.Binding
|
||||
recovery = ©
|
||||
identity = copy.BindingID + copy.CoreID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if desired != endpoint {
|
||||
if desired != endpoint || (desired != "" && (identity != serverIdentity || time.Now().After(refreshAt))) {
|
||||
closeServer()
|
||||
if desired != "" {
|
||||
u, _ := url.Parse(desired)
|
||||
cert, e := bootstrapCertificate(p.store.pairingKey(), u.Hostname())
|
||||
config := &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}
|
||||
handler := p.remoteHandler()
|
||||
if recovery != nil {
|
||||
config, e = recoveryTLS(*recovery, p.store.pairingKey(), u.Hostname())
|
||||
handler = p.recoveryHandler()
|
||||
}
|
||||
var listener net.Listener
|
||||
if e == nil {
|
||||
listener, e = net.Listen("tcp4", u.Host)
|
||||
}
|
||||
p.mu.Lock()
|
||||
if e != nil {
|
||||
p.listenError = "Не удалось открыть частное подключение. Проверьте адрес и создайте приглашение повторно."
|
||||
p.listenError = "Не удалось открыть частное подключение. Проверьте доступность выбранной сети."
|
||||
} else {
|
||||
p.listenError = ""
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if e == nil {
|
||||
server = &http.Server{Handler: p.remoteHandler(), ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS13, Certificates: []tls.Certificate{cert}}}
|
||||
server = &http.Server{Handler: handler, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 5 * time.Second, MaxHeaderBytes: 8192, TLSConfig: config}
|
||||
endpoint = desired
|
||||
serverIdentity = identity
|
||||
refreshAt = time.Now().Add(12 * time.Hour)
|
||||
go func(s *http.Server, l net.Listener) { _ = s.Serve(tls.NewListener(l, s.TLSConfig)) }(server, &pairingListener{Listener: listener, slots: make(chan struct{}, 16), done: make(chan struct{})})
|
||||
}
|
||||
}
|
||||
@@ -209,7 +233,7 @@ func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload
|
||||
if e != nil {
|
||||
return nil, 0, e
|
||||
}
|
||||
cacheKey := b.BindingID + digest(b.ClientPEM)
|
||||
cacheKey := b.BindingID + b.Endpoint + digest(b.ClientPEM)
|
||||
client := p.clients[cacheKey]
|
||||
if client == nil {
|
||||
transport := &http.Transport{TLSClientConfig: config, Proxy: nil, MaxConnsPerHost: 1, MaxIdleConnsPerHost: 1, IdleConnTimeout: 15 * time.Second, TLSHandshakeTimeout: 4 * time.Second, DialContext: (&net.Dialer{Timeout: 4 * time.Second}).DialContext}
|
||||
@@ -262,10 +286,10 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
p.mu.Unlock()
|
||||
wanted := make(map[string]bool)
|
||||
if binding != nil {
|
||||
wanted[binding.BindingID+digest(binding.ClientPEM)] = true
|
||||
wanted[binding.BindingID+binding.Endpoint+digest(binding.ClientPEM)] = true
|
||||
}
|
||||
for _, b := range revocations {
|
||||
wanted[b.BindingID+digest(b.ClientPEM)] = true
|
||||
wanted[b.BindingID+b.Endpoint+digest(b.ClientPEM)] = true
|
||||
}
|
||||
for key, c := range p.clients {
|
||||
if !wanted[key] {
|
||||
@@ -276,6 +300,8 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
if binding != nil {
|
||||
id, name := p.store.Public()
|
||||
payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}}
|
||||
payload["core_endpoint"] = binding.Endpoint
|
||||
payload["endpoint_revision"] = binding.EndpointRevision
|
||||
if p.Sensors != nil {
|
||||
inv := p.Sensors.Inventory()
|
||||
payload["devices"] = inv["items"]
|
||||
@@ -291,7 +317,7 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
}
|
||||
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
|
||||
p.mu.Lock()
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
|
||||
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID && p.state.Binding.Endpoint == binding.Endpoint && p.state.Binding.EndpointRevision == binding.EndpointRevision {
|
||||
if e == nil && status == 200 {
|
||||
if p.Monitor != nil {
|
||||
p.Monitor.Acknowledge(result["monitor_ack"])
|
||||
|
||||
@@ -30,6 +30,7 @@ func TestUSBEventsAreHintsAndDoNotMultiplyInterfaces(t *testing.T) {
|
||||
func TestSensorEventStreamRequiresLocalSession(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.Sensors, _ = OpenSensors(t.TempDir(), "node_test")
|
||||
isolateSensorHost(t, s.Sensors)
|
||||
if response := call(s, "GET", "/api/devices/events", "", nil); response.Code != 401 {
|
||||
t.Fatal("unauthenticated device stream", response.Code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Model bindings are shipped code, never executables or paths supplied by a
|
||||
// browser. A model owns its driver/profile; a physical device owns its session.
|
||||
type sensorModel struct {
|
||||
ID, Name, Prefix, Kind, Plugin, Version string
|
||||
Vendor, Product, USBName string
|
||||
Socket, PrepareUnit, Report string
|
||||
Actions map[string]bool
|
||||
}
|
||||
|
||||
func actions(names ...string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, name := range names {
|
||||
out[name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var sensorModels = []sensorModel{
|
||||
{ID: "realsense.d455", Name: "RealSense D455", Prefix: "rsd455", Plugin: "missioncore.realsense", Version: "0.6.6",
|
||||
Vendor: "8086", Product: "0b5c", Socket: "/run/mission-core-sensors/driver.sock",
|
||||
PrepareUnit: "mission-core-node-realsense-prepare.service", Report: "/var/lib/mission-core-node-drivers/preparation.json",
|
||||
Actions: actions("prepare", "details", "rename", "verify", "start", "replay", "stop", "option", "offer", "close-peer")},
|
||||
{ID: "xgrids.k1", Name: "XGRIDS K1", Prefix: "k1", Kind: "k1",
|
||||
Actions: actions("details", "rename", "verify", "start", "stop", "option", "offer", "close-peer")},
|
||||
{ID: "insta360.x4", Name: "Insta360 X4", Prefix: "instax4", Kind: "insta360.x4", Plugin: "missioncore.insta360", Version: "0.1.3",
|
||||
// 2e1a:0002 is shared with other Insta360 models. Require the exact
|
||||
// OS product descriptor as well; SDK identity is verified after prepare.
|
||||
Vendor: "2e1a", Product: "0002", USBName: "Insta360 X4", Socket: "/run/mission-core-insta360/driver.sock",
|
||||
PrepareUnit: "mission-core-node-insta360-x4-profile.service", Report: "/var/lib/mission-core-node-profiles/insta360-x4/preparation.json",
|
||||
Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer")},
|
||||
}
|
||||
|
||||
func modelForDevice(id string) *sensorModel {
|
||||
if !sensorID.MatchString(id) {
|
||||
return nil
|
||||
}
|
||||
for i := range sensorModels {
|
||||
if strings.HasPrefix(id, sensorModels[i].Prefix+"_") {
|
||||
return &sensorModels[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentCameraSnapshot(item map[string]any, model *sensorModel) bool {
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
context, _ := snapshot["context"].(map[string]any)
|
||||
device, _ := context["device"].(map[string]any)
|
||||
installed, _ := device["model"].(map[string]any)
|
||||
_, revision := snapshot["revision"].(float64) // Decoded driver JSON.
|
||||
observed, _ := snapshot["observed_at"].(string)
|
||||
_, err := time.Parse(time.RFC3339Nano, observed)
|
||||
return revision && err == nil && installed["plugin_version"] == model.Version && installed["model_id"] == model.ID
|
||||
}
|
||||
|
||||
func modelDeviceID(model *sensorModel, serial string) string {
|
||||
h := sha256.Sum256([]byte(serial))
|
||||
return model.Prefix + "_" + hex.EncodeToString(h[:])[:32]
|
||||
}
|
||||
|
||||
func sensorClient(socket string) *http.Client {
|
||||
return &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
}}}
|
||||
}
|
||||
|
||||
type usbSensor struct {
|
||||
model *sensorModel
|
||||
id, speed, binding string
|
||||
stable bool
|
||||
}
|
||||
|
||||
// Read-only OS discovery works before any vendor runtime has been installed.
|
||||
// An absent or duplicated serial is not enough authority to initialize a unit.
|
||||
func discoverSensors(root string) []usbSensor {
|
||||
paths, _ := filepath.Glob(filepath.Join(root, "*"))
|
||||
items := []usbSensor{}
|
||||
counts := map[string]int{}
|
||||
for _, path := range paths {
|
||||
read := func(name string) string {
|
||||
data, _ := os.ReadFile(filepath.Join(path, name))
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
for i := range sensorModels {
|
||||
model := &sensorModels[i]
|
||||
if model.Vendor == "" || read("idVendor") != model.Vendor || read("idProduct") != model.Product || (model.USBName != "" && read("product") != model.USBName) {
|
||||
continue
|
||||
}
|
||||
serial := read("serial")
|
||||
id := modelDeviceID(model, serial)
|
||||
counts[id]++
|
||||
items = append(items, usbSensor{model: model, id: id, speed: read("speed") + " Мбит/с", binding: filepath.Base(path) + ":" + read("devnum"), stable: serial != ""})
|
||||
}
|
||||
}
|
||||
unique := []usbSensor{}
|
||||
for _, item := range items {
|
||||
if !item.stable || counts[item.id] != 1 {
|
||||
item.stable = false
|
||||
item.id = modelDeviceID(item.model, "provisional:"+item.binding)
|
||||
}
|
||||
unique = append(unique, item)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
type discoverySession struct {
|
||||
binding, session string
|
||||
stable bool
|
||||
}
|
||||
|
||||
func (s *Sensors) reconcileDiscovery(devices []usbSensor) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
live := map[string]discoverySession{}
|
||||
for _, device := range devices {
|
||||
previous := s.discoverySessions[device.id]
|
||||
if previous.binding != device.binding || previous.session == "" {
|
||||
previous = discoverySession{device.binding, "discovery_" + digest(token())[:24] + "_" + device.id, device.stable}
|
||||
}
|
||||
live[device.id] = previous
|
||||
}
|
||||
s.discoverySessions = live
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeUSB(t *testing.T, root, port, serial, product, number string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, port)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range map[string]string{"idVendor": "2e1a", "idProduct": "0002", "product": product, "serial": serial, "devnum": number, "speed": "5000"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, key), []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testSensorReply(value any) *http.Response {
|
||||
data, _ := json.Marshal(value)
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(string(data))), Header: http.Header{}}
|
||||
}
|
||||
|
||||
func isolatedSensors(t *testing.T) *Sensors {
|
||||
t.Helper()
|
||||
s, err := OpenSensors(t.TempDir(), "node_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.usbRoot = t.TempDir()
|
||||
for _, client := range s.clients {
|
||||
client.Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return testSensorReply(map[string]any{"items": []any{}}), nil
|
||||
})
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestModelDiscoveryIdentityAndHotplug(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
fakeUSB(t, s.usbRoot, "3-3", "synthetic-b", "Insta360 X4", "3")
|
||||
fakeUSB(t, s.usbRoot, "3-4", "synthetic-other", "Insta360 OneR", "4")
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("wrong models admitted: %d", len(items))
|
||||
}
|
||||
first := items[0].(map[string]any)
|
||||
id := first["id"].(string)
|
||||
if id == items[1].(map[string]any)["id"] || first["prepared"] != false || first["configured"] != false {
|
||||
t.Fatal("instances collapsed or discovery claimed readiness")
|
||||
}
|
||||
session := sensorSessionID(first)
|
||||
if sensorSessionID(s.Inventory()["items"].([]any)[0].(map[string]any)) != session {
|
||||
t.Fatal("refresh changed session")
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(s.usbRoot, "3-2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Inventory()
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "7")
|
||||
again := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
if again["id"] != id || sensorSessionID(again) == session {
|
||||
t.Fatal("replug did not retain identity and renew session")
|
||||
}
|
||||
// D455 IDs use the original unnamespaced serial hash, preserving archives.
|
||||
if got := modelDeviceID(&sensorModels[0], "abc"); got != "rsd455_ba7816bf8f01cfea414140de5dae2223" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if sensorModels[1].Kind != "k1" {
|
||||
t.Fatal("K1 contribution binding changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateSerialCannotAcquireCameraAuthority(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fakeUSB(t, root, "3-2", "duplicate", "Insta360 X4", "2")
|
||||
fakeUSB(t, root, "3-3", "duplicate", "Insta360 X4", "3")
|
||||
items := discoverSensors(root)
|
||||
if len(items) != 2 || items[0].id == items[1].id || items[0].stable || items[1].stable {
|
||||
t.Fatal("ambiguous USB devices disappeared or acquired stable authority")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyCameraProfileKeepsRemoteInitializationAvailable(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-legacy", "Insta360 X4", "2")
|
||||
model := &sensorModels[2]
|
||||
id := modelDeviceID(model, "synthetic-legacy")
|
||||
legacy := s.discovery(id, "5000", true)
|
||||
legacy["prepared"] = true
|
||||
snapshot := legacy["snapshot"].(map[string]any)
|
||||
delete(snapshot, "observed_at")
|
||||
delete(snapshot, "revision")
|
||||
s.clients[model.ID].Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return testSensorReply(map[string]any{"items": []any{legacy}}), nil
|
||||
})
|
||||
for _, state := range []string{"idle", "live", "unknown"} {
|
||||
snapshot["acquisition"] = state
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatal("legacy runtime hid or duplicated the USB camera")
|
||||
}
|
||||
item := items[0].(map[string]any)
|
||||
fresh := item["snapshot"].(map[string]any)
|
||||
if item["prepared"] != false || fresh["observed_at"] == nil || fresh["revision"] == nil || sensorSessionID(item) == "" {
|
||||
t.Fatal("legacy snapshot escaped the discovery fallback")
|
||||
}
|
||||
if (state != "idle") != (item["preparation_safe"] == false) {
|
||||
t.Fatal("legacy acquisition safety was lost")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func awaitSensor(t *testing.T, predicate func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if predicate() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("sensor operation timed out")
|
||||
}
|
||||
|
||||
func TestNewCameraUsesReadyProfileWithoutInterruptingAnotherInstance(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-active", "Insta360 X4", "2")
|
||||
fakeUSB(t, s.usbRoot, "3-3", "synthetic-new", "Insta360 X4", "3")
|
||||
active := modelDeviceID(&sensorModels[2], "synthetic-active")
|
||||
newDevice := modelDeviceID(&sensorModels[2], "synthetic-new")
|
||||
var runs atomic.Int32
|
||||
var stopped atomic.Bool
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
s.runPreparation = func(context.Context, string) error {
|
||||
runs.Add(1)
|
||||
return errors.New("must not redeploy a ready shared profile")
|
||||
}
|
||||
s.clients["insta360.x4"].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/inventory" {
|
||||
items := []any{}
|
||||
for _, id := range []string{active, newDevice} {
|
||||
item := s.discovery(id, "5000", true)
|
||||
item["prepared"] = true
|
||||
item["preparation_safe"] = id != active
|
||||
snapshot := item["snapshot"].(map[string]any)
|
||||
snapshot["context"].(map[string]any)["session_id"] = "sdk_" + id
|
||||
if id == active { snapshot["acquisition"] = "streaming" }
|
||||
items = append(items, item)
|
||||
}
|
||||
return testSensorReply(map[string]any{"items": items}), nil
|
||||
}
|
||||
var command SensorCommand
|
||||
_ = json.NewDecoder(r.Body).Decode(&command)
|
||||
if command.Action == "verify" && command.Session.DeviceID == newDevice {
|
||||
<-release
|
||||
} else if command.Action == "preview.stop" && command.Session.DeviceID == active {
|
||||
stopped.Store(true)
|
||||
} else {
|
||||
return nil, errors.New("cross-camera command")
|
||||
}
|
||||
return testSensorReply(map[string]any{"state": "complete", "result": map[string]bool{"ok": true}}), nil
|
||||
})
|
||||
s.Inventory()
|
||||
command := sensorTestCommand()
|
||||
command.Action = "prepare"
|
||||
command.Session = SensorSession{DeviceID: newDevice, SessionID: "sdk_" + newDevice}
|
||||
if _, err := s.Submit(command, true); err != nil { t.Fatal(err) }
|
||||
awaitSensor(t, func() bool { op := s.Get(command.ID); return op.Preparation != nil && op.Preparation.Phase == "verify" })
|
||||
stop := sensorTestCommand()
|
||||
stop.ID = "op_" + strings.Repeat("b", 32)
|
||||
stop.Idempotency = stop.ID
|
||||
stop.Action = "preview.stop"
|
||||
stop.Session = SensorSession{DeviceID: active, SessionID: "sdk_" + active}
|
||||
if _, err := s.Submit(stop, false); err != nil { t.Fatal(err) }
|
||||
awaitSensor(t, func() bool { return stopped.Load() })
|
||||
if runs.Load() != 0 { t.Fatal("new-camera preparation redeployed the shared profile") }
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
awaitSensor(t, func() bool { return s.Get(command.ID).State == "complete" })
|
||||
}
|
||||
|
||||
func TestLocalAndRemotePrepareShareProfileButVerifyEachInstance(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
fakeUSB(t, s.usbRoot, "3-3", "synthetic-b", "Insta360 X4", "3")
|
||||
initial := s.Inventory()["items"].([]any)
|
||||
var installed atomic.Bool
|
||||
var runs atomic.Int32
|
||||
var mu sync.Mutex
|
||||
verified := []string{}
|
||||
release := make(chan struct{})
|
||||
s.runPreparation = func(ctx context.Context, unit string) error {
|
||||
if unit != "mission-core-node-insta360-x4-profile.service" {
|
||||
return errors.New("wrong unit")
|
||||
}
|
||||
runs.Add(1)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-release:
|
||||
}
|
||||
installed.Store(true)
|
||||
return nil
|
||||
}
|
||||
s.clients["insta360.x4"].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/inventory" {
|
||||
items := []any{}
|
||||
if installed.Load() {
|
||||
for _, raw := range initial {
|
||||
old := raw.(map[string]any)
|
||||
id := old["id"].(string)
|
||||
item := s.discovery(id, "5000", true)
|
||||
item["prepared"] = true
|
||||
item["snapshot"].(map[string]any)["context"].(map[string]any)["session_id"] = "sdk_" + id
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return testSensorReply(map[string]any{"items": items}), nil
|
||||
}
|
||||
var c SensorCommand
|
||||
_ = json.NewDecoder(r.Body).Decode(&c)
|
||||
if c.Action != "verify" || c.Session.SessionID != "sdk_"+c.Session.DeviceID {
|
||||
return nil, errors.New("wrong instance session")
|
||||
}
|
||||
mu.Lock()
|
||||
verified = append(verified, c.Session.DeviceID)
|
||||
mu.Unlock()
|
||||
if c.Session.DeviceID == initial[1].(map[string]any)["id"] {
|
||||
return testSensorReply(map[string]any{"state": "error", "error": "no frames"}), nil
|
||||
}
|
||||
return testSensorReply(map[string]any{"state": "complete", "result": map[string]any{"device_id": c.Session.DeviceID}}), nil
|
||||
})
|
||||
// An active camera of another model must not block X4 installation.
|
||||
s.client.Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
item := s.discovery(sensorTestCommand().Session.DeviceID, "5000", true)
|
||||
item["snapshot"].(map[string]any)["acquisition"] = "streaming"
|
||||
return testSensorReply(map[string]any{"items": []any{item}}), nil
|
||||
})
|
||||
commands := []SensorCommand{}
|
||||
for i, raw := range initial {
|
||||
item := raw.(map[string]any)
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.ID = "op_" + strings.Repeat(string(rune('a'+i)), 32)
|
||||
c.Idempotency = c.ID
|
||||
c.Session = SensorSession{DeviceID: item["id"].(string), SessionID: sensorSessionID(item)}
|
||||
commands = append(commands, c)
|
||||
if _, err := s.Submit(c, i == 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
awaitSensor(t, func() bool {
|
||||
return s.Get(commands[0].ID).Preparation != nil && s.Get(commands[1].ID).Preparation != nil
|
||||
})
|
||||
close(release)
|
||||
awaitSensor(t, func() bool {
|
||||
return s.Get(commands[0].ID).State != "running" && s.Get(commands[1].ID).State != "running"
|
||||
})
|
||||
if runs.Load() != 1 {
|
||||
t.Fatalf("deployed %d times", runs.Load())
|
||||
}
|
||||
if s.Get(commands[0].ID).State != "complete" || s.Get(commands[1].ID).State != "error" {
|
||||
t.Fatal("instance outcomes mixed")
|
||||
}
|
||||
s.mu.Lock()
|
||||
a, b := s.initialized[commands[0].Session.DeviceID], s.initialized[commands[1].Session.DeviceID]
|
||||
s.mu.Unlock()
|
||||
if !a || b {
|
||||
t.Fatal("success initialized a different camera")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(verified) != 2 || verified[0] == verified[1] {
|
||||
t.Fatal("verification routed to same instance")
|
||||
}
|
||||
if len(s.RemoteResults()) != 1 {
|
||||
t.Fatal("remote result was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalePrepareAndCrossModelActionsCannotMutate(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
item := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
var calls atomic.Int32
|
||||
s.runPreparation = func(context.Context, string) error { calls.Add(1); return nil }
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.Session.DeviceID = item["id"].(string)
|
||||
if _, err := s.Submit(c, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return s.Get(c.ID).State != "running" })
|
||||
if calls.Load() != 0 || s.Get(c.ID).State != "error" {
|
||||
t.Fatal("stale session initialized a camera")
|
||||
}
|
||||
c = sensorTestCommand()
|
||||
c.Action = "record.start"
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("X4 recording command admitted for D455")
|
||||
}
|
||||
c.Session.DeviceID = item["id"].(string)
|
||||
c.Action = "start"
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("legacy ambiguous start admitted for X4")
|
||||
}
|
||||
c.Action = "prepare"
|
||||
c.Parameters = map[string]any{"unit": "unrelated.service"}
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("caller chose privileged profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingWithoutPreviewBlocksModelPreparation(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeUSB(t, s.usbRoot, "3-2", "synthetic-a", "Insta360 X4", "2")
|
||||
item := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
// The camera's SD recording is independent from acquisition/preview.
|
||||
item["prepared"] = true
|
||||
item["preparation_safe"] = false
|
||||
s.clients["insta360.x4"].Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return testSensorReply(map[string]any{"items": []any{item}}), nil
|
||||
})
|
||||
var calls atomic.Int32
|
||||
s.runPreparation = func(context.Context, string) error { calls.Add(1); return nil }
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.Session = SensorSession{DeviceID: item["id"].(string), SessionID: sensorSessionID(item)}
|
||||
if _, err := s.Submit(c, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return s.Get(c.ID).State != "running" })
|
||||
if s.Get(c.ID).State != "error" || calls.Load() != 0 {
|
||||
t.Fatal("preparation changed a model with active camera recording")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errPreparationUncertain = errors.New("Результат подготовки неизвестен. Обновите состояние устройства.")
|
||||
|
||||
type preparationStep struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
State string `json:"state"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type sensorPreparation struct {
|
||||
OperationID string `json:"operation_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
StartedAt float64 `json:"started_at"`
|
||||
ProfileStartedAt float64 `json:"profile_started_at"`
|
||||
State string `json:"state"`
|
||||
Phase string `json:"phase"`
|
||||
Steps []preparationStep `json:"steps"`
|
||||
}
|
||||
|
||||
type profilePreparation struct {
|
||||
done chan struct{}
|
||||
started float64
|
||||
err error
|
||||
users int
|
||||
finished bool
|
||||
}
|
||||
|
||||
func runModelPreparation(ctx context.Context, unit string) error {
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", unit)
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"}
|
||||
if cmd.Run() != nil {
|
||||
if ctx.Err() != nil {
|
||||
return errPreparationUncertain
|
||||
}
|
||||
return errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sensorSessionID(item map[string]any) string {
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
context, _ := snapshot["context"].(map[string]any)
|
||||
session, _ := context["session_id"].(string)
|
||||
return session
|
||||
}
|
||||
|
||||
func (s *Sensors) preparationPhase(c SensorCommand, model *sensorModel, job *profilePreparation, phase, state string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
op := s.operations[c.ID]
|
||||
if op == nil {
|
||||
return errors.New("Операция подготовки не найдена.")
|
||||
}
|
||||
started, _ := time.Parse(time.RFC3339Nano, c.Requested)
|
||||
deploy, verify := "running", "pending"
|
||||
if phase == "verify" {
|
||||
deploy, verify = "complete", "running"
|
||||
}
|
||||
if state != "running" {
|
||||
if phase == "profile" {
|
||||
deploy, verify = state, "blocked"
|
||||
} else {
|
||||
verify = state
|
||||
}
|
||||
}
|
||||
op.Preparation = &sensorPreparation{OperationID: c.ID, DeviceID: c.Session.DeviceID, ModelID: model.ID,
|
||||
StartedAt: float64(started.UnixMilli()) / 1000, ProfileStartedAt: job.started, State: state, Phase: phase,
|
||||
Steps: []preparationStep{{ID: "profile", Label: "Подготовка драйвера", State: deploy}, {ID: "verify", Label: "Проверка выбранной камеры", State: verify}}}
|
||||
op.Updated = time.Now().Unix()
|
||||
err := s.write(c.ID+".json", op)
|
||||
s.events.notify()
|
||||
return err
|
||||
}
|
||||
|
||||
// A concurrent prepare for another instance of this model joins the same
|
||||
// deployment. Each command subsequently verifies only its own physical unit.
|
||||
func (s *Sensors) profileJob(model *sensorModel) *profilePreparation {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if job := s.preparing[model.ID]; job != nil {
|
||||
job.users++
|
||||
return job
|
||||
}
|
||||
job := &profilePreparation{done: make(chan struct{}), started: float64(time.Now().UnixMilli()) / 1000, users: 1}
|
||||
s.preparing[model.ID] = job
|
||||
go func() {
|
||||
// Only this profile's devices can be affected by its service activation.
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
id, _ := item["id"].(string)
|
||||
if modelForDevice(id) != model {
|
||||
continue
|
||||
}
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
if state := snapshot["acquisition"]; (state != "idle" && state != "failed") || item["preparation_safe"] == false {
|
||||
job.err = errors.New("Остановите захват устройств этой модели перед подготовкой драйвера.")
|
||||
break
|
||||
}
|
||||
}
|
||||
if job.err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
|
||||
job.err = s.runPreparation(ctx, model.PrepareUnit)
|
||||
cancel()
|
||||
}
|
||||
s.mu.Lock()
|
||||
job.finished = true
|
||||
if job.users == 0 {
|
||||
delete(s.preparing, model.ID)
|
||||
}
|
||||
close(job.done)
|
||||
s.mu.Unlock()
|
||||
s.events.notify()
|
||||
}()
|
||||
return job
|
||||
}
|
||||
|
||||
func reusableCameraProfile(item map[string]any, model *sensorModel) bool {
|
||||
if item["configured"] == true || item["prepared"] != true || item["preparation_safe"] == false {
|
||||
return false
|
||||
}
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
if snapshot["acquisition"] != "idle" {
|
||||
return false
|
||||
}
|
||||
context, _ := snapshot["context"].(map[string]any)
|
||||
device, _ := context["device"].(map[string]any)
|
||||
installed, _ := device["model"].(map[string]any)
|
||||
return installed["plugin_version"] == model.Version && installed["model_id"] == model.ID
|
||||
}
|
||||
|
||||
func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any, err error) {
|
||||
model := modelForDevice(c.Session.DeviceID)
|
||||
if model == nil || model.PrepareUnit == "" {
|
||||
return nil, errors.New("Подготовка этой модели не поддерживается.")
|
||||
}
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
defer cancel()
|
||||
if ctx.Err() != nil {
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
s.mu.Lock()
|
||||
initialBinding := s.discoverySessions[c.Session.DeviceID].binding
|
||||
s.mu.Unlock()
|
||||
// A newly attached camera can use the already running admitted profile.
|
||||
// Verification belongs to this camera and need not stop other instances.
|
||||
reuse := reusableCameraProfile(selected, model)
|
||||
s.mu.Lock()
|
||||
if active := s.preparing[model.ID]; active != nil && !active.finished {
|
||||
reuse = false
|
||||
}
|
||||
s.mu.Unlock()
|
||||
var job *profilePreparation
|
||||
if reuse {
|
||||
job = &profilePreparation{done: make(chan struct{}), started: float64(time.Now().UnixMilli()) / 1000, users: 1, finished: true}
|
||||
close(job.done)
|
||||
} else {
|
||||
job = s.profileJob(model)
|
||||
}
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
job.users--
|
||||
if job.users == 0 && job.finished && s.preparing[model.ID] == job {
|
||||
delete(s.preparing, model.ID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
phase := "profile"
|
||||
defer func() {
|
||||
state := "complete"
|
||||
if err != nil {
|
||||
state = "error"
|
||||
}
|
||||
if errors.Is(err, errPreparationUncertain) {
|
||||
state = "unknown"
|
||||
}
|
||||
if e := s.preparationPhase(c, model, job, phase, state); e != nil {
|
||||
err = errPreparationUncertain
|
||||
}
|
||||
}()
|
||||
if s.preparationPhase(c, model, job, phase, "running") != nil {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, errPreparationUncertain
|
||||
case <-job.done:
|
||||
if job.err != nil {
|
||||
return nil, job.err
|
||||
}
|
||||
}
|
||||
phase = "verify"
|
||||
if s.preparationPhase(c, model, job, phase, "running") != nil {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
// X4 bounds vendor Open at 40 seconds; allow that startup window without
|
||||
// treating an active unit as a verified device. The command deadline still
|
||||
// bounds the observer independently of the root preparation transaction.
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
if ctx.Err() != nil {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
inventory := s.Inventory()
|
||||
s.mu.Lock()
|
||||
binding := s.discoverySessions[c.Session.DeviceID].binding
|
||||
s.mu.Unlock()
|
||||
if initialBinding != "" && binding != initialBinding {
|
||||
return nil, errors.New("Камера переподключена во время подготовки. Повторите проверку устройства.")
|
||||
}
|
||||
for _, raw := range inventory["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["id"] != c.Session.DeviceID || item["online"] != true || item["prepared"] != true {
|
||||
continue
|
||||
}
|
||||
verify := c
|
||||
verify.Action = "verify"
|
||||
verify.Session.SessionID = sensorSessionID(item)
|
||||
if verify.Session.SessionID == "" {
|
||||
return nil, errors.New("Драйвер не подтвердил сеанс камеры.")
|
||||
}
|
||||
response, e := s.modelDriver(ctx, model, "/operation", verify)
|
||||
if e != nil || response["state"] == "unknown" {
|
||||
return nil, errPreparationUncertain
|
||||
}
|
||||
if response["state"] != "complete" {
|
||||
message, _ := response["error"].(string)
|
||||
if message == "" {
|
||||
message = "Не удалось проверить изображение выбранной камеры."
|
||||
}
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
return response["result"], nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, errPreparationUncertain
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
|
||||
}
|
||||
|
||||
// Project only the matching model run. A previous success or another model's
|
||||
// report cannot become the progress of the selected device's verification.
|
||||
func preparationView(value *sensorPreparation) *sensorPreparation {
|
||||
copy := *value
|
||||
model := modelForDevice(value.DeviceID)
|
||||
if model == nil || len(value.Steps) == 0 {
|
||||
return ©
|
||||
}
|
||||
data, err := os.ReadFile(model.Report)
|
||||
if err != nil || len(data) > 32768 {
|
||||
return ©
|
||||
}
|
||||
var report struct {
|
||||
ModelID string `json:"model_id"`
|
||||
StartedAt float64 `json:"started_at"`
|
||||
Steps []preparationStep `json:"steps"`
|
||||
}
|
||||
if json.Unmarshal(data, &report) != nil || report.ModelID != value.ModelID || report.StartedAt < value.ProfileStartedAt || len(report.Steps) == 0 || len(report.Steps) > 32 {
|
||||
return ©
|
||||
}
|
||||
copy.Steps = append(report.Steps, value.Steps[len(value.Steps)-1])
|
||||
return ©
|
||||
}
|
||||
@@ -3,15 +3,11 @@ package node
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -37,28 +33,34 @@ type SensorCommand struct {
|
||||
Parameters map[string]any `json:"parameters"`
|
||||
}
|
||||
type SensorOperation struct {
|
||||
Command SensorCommand `json:"command"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Remote bool `json:"remote,omitempty"`
|
||||
Updated int64 `json:"updated_at"`
|
||||
Command SensorCommand `json:"command"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Remote bool `json:"remote,omitempty"`
|
||||
Updated int64 `json:"updated_at"`
|
||||
Preparation *sensorPreparation `json:"preparation,omitempty"`
|
||||
}
|
||||
type Sensors struct {
|
||||
events sensorEvents
|
||||
mu sync.Mutex
|
||||
prepareMu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
instance string
|
||||
client *http.Client
|
||||
NetworkDevices *DeviceEnrollment
|
||||
operations map[string]*SensorOperation
|
||||
names map[string]string
|
||||
initialized map[string]bool
|
||||
events sensorEvents
|
||||
mu sync.Mutex
|
||||
inventoryMu sync.Mutex
|
||||
root string
|
||||
nodeID string
|
||||
instance string
|
||||
client *http.Client
|
||||
clients map[string]*http.Client
|
||||
usbRoot string
|
||||
discoverySessions map[string]discoverySession
|
||||
preparing map[string]*profilePreparation
|
||||
runPreparation func(context.Context, string) error
|
||||
NetworkDevices *DeviceEnrollment
|
||||
operations map[string]*SensorOperation
|
||||
names map[string]string
|
||||
initialized map[string]bool
|
||||
}
|
||||
|
||||
var sensorID = regexp.MustCompile(`^(rsd455|k1)_[0-9a-f]{32}$`)
|
||||
var sensorID = regexp.MustCompile(`^[a-z][a-z0-9]{1,31}_[0-9a-f]{32}$`)
|
||||
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
|
||||
|
||||
func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
@@ -66,10 +68,13 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
if e := os.MkdirAll(dir, 0700); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}, initialized: map[string]bool{}}
|
||||
s.client = &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-sensors/driver.sock")
|
||||
}}}
|
||||
s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}, initialized: map[string]bool{}, clients: map[string]*http.Client{}, usbRoot: "/sys/bus/usb/devices", discoverySessions: map[string]discoverySession{}, preparing: map[string]*profilePreparation{}, runPreparation: runModelPreparation}
|
||||
for _, model := range sensorModels {
|
||||
if model.Socket != "" {
|
||||
s.clients[model.ID] = sensorClient(model.Socket)
|
||||
}
|
||||
}
|
||||
s.client = s.clients["realsense.d455"]
|
||||
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
|
||||
for _, p := range files {
|
||||
data, e := os.ReadFile(p)
|
||||
@@ -83,11 +88,17 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
if v.State == "running" {
|
||||
v.State = "unknown"
|
||||
v.Error = "Результат операции неизвестен после перезапуска. Проверьте состояние устройства."
|
||||
if v.Preparation != nil {
|
||||
v.Preparation.State = "unknown"
|
||||
}
|
||||
}
|
||||
s.operations[v.Command.ID] = &v
|
||||
}
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "names.json"))
|
||||
_ = json.Unmarshal(data, &s.names)
|
||||
if s.names == nil {
|
||||
s.names = map[string]string{}
|
||||
}
|
||||
data, _ = os.ReadFile(filepath.Join(dir, "initialized.json"))
|
||||
_ = json.Unmarshal(data, &s.initialized)
|
||||
if s.initialized == nil {
|
||||
@@ -133,6 +144,25 @@ func (s *Sensors) write(name string, value any) error {
|
||||
return d.Sync()
|
||||
}
|
||||
func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
return s.modelDriver(context.Background(), &sensorModels[0], path, body)
|
||||
}
|
||||
func (s *Sensors) modelDriver(ctx context.Context, model *sensorModel, path string, body any) (map[string]any, error) {
|
||||
if model.Prefix == "k1" {
|
||||
if s.NetworkDevices == nil {
|
||||
return nil, errors.New("Служба устройства недоступна.")
|
||||
}
|
||||
if path == "/operation" {
|
||||
path = "/sensor-operation"
|
||||
}
|
||||
return s.NetworkDevices.call(ctx, path, body)
|
||||
}
|
||||
client := s.clients[model.ID]
|
||||
if model.ID == "realsense.d455" {
|
||||
client = s.client
|
||||
}
|
||||
if client == nil {
|
||||
return nil, errors.New("Интеграция устройства не установлена.")
|
||||
}
|
||||
method := "GET"
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
@@ -143,7 +173,7 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, e := http.NewRequest(method, "http://driver"+path, reader)
|
||||
req, e := http.NewRequestWithContext(ctx, method, "http://driver"+path, reader)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -151,13 +181,14 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, e := s.client.Do(req)
|
||||
response, e := client.Do(req)
|
||||
if e != nil {
|
||||
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result map[string]any
|
||||
if json.NewDecoder(io.LimitReader(response.Body, 1024*1024)).Decode(&result) != nil {
|
||||
data, readErr := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024+1))
|
||||
if readErr != nil || len(data) > 2*1024*1024 || json.Unmarshal(data, &result) != nil {
|
||||
return nil, errors.New("Не удалось прочитать результат драйвера.")
|
||||
}
|
||||
if response.StatusCode != 200 {
|
||||
@@ -167,78 +198,69 @@ func (s *Sensors) driver(path string, body any) (map[string]any, error) {
|
||||
return result, nil
|
||||
}
|
||||
func (s *Sensors) Inventory() map[string]any {
|
||||
// Concurrent local/remote refreshes must observe the same hotplug generation.
|
||||
s.inventoryMu.Lock()
|
||||
defer s.inventoryMu.Unlock()
|
||||
usb := discoverSensors(s.usbRoot)
|
||||
s.reconcileDiscovery(usb)
|
||||
items := []any{}
|
||||
seen := map[string]bool{}
|
||||
if s.NetworkDevices != nil {
|
||||
unsafePreparation := map[string]bool{}
|
||||
for idx := range sensorModels {
|
||||
model := &sensorModels[idx]
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
result, e := s.NetworkDevices.call(ctx, "/inventory", nil)
|
||||
result, err := s.modelDriver(ctx, model, "/inventory", nil)
|
||||
cancel()
|
||||
if e == nil {
|
||||
if found, ok := result["items"].([]any); ok {
|
||||
for _, raw := range found {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
if !strings.HasPrefix(id, "k1_") || !sensorID.MatchString(id) {
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
if name := s.names[id]; name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
s.mu.Unlock()
|
||||
seen[id] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if result, e := s.driver("/inventory", nil); e == nil {
|
||||
if found, ok := result["items"].([]any); ok {
|
||||
for _, v := range found {
|
||||
item, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
seen[id] = true
|
||||
s.mu.Lock()
|
||||
found, _ := result["items"].([]any)
|
||||
for _, raw := range found {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := item["id"].(string)
|
||||
if modelForDevice(id) != model || seen[id] {
|
||||
continue
|
||||
}
|
||||
if model.ID == "insta360.x4" && !currentCameraSnapshot(item, model) {
|
||||
// Older installed profiles must not break the paired inventory.
|
||||
// USB discovery still exposes their initialization action, while
|
||||
// preserving a legacy runtime's refusal to interrupt acquisition.
|
||||
snapshot, _ := item["snapshot"].(map[string]any)
|
||||
unsafePreparation[id] = item["preparation_safe"] == false || snapshot["acquisition"] != "idle"
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
if model.PrepareUnit != "" {
|
||||
item["configured"] = s.initialized[id]
|
||||
if n := s.names[id]; n != "" {
|
||||
item["name"] = n
|
||||
}
|
||||
s.mu.Unlock()
|
||||
items = append(items, item)
|
||||
}
|
||||
if name := s.names[id]; name != "" {
|
||||
item["name"] = name
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if model.Kind != "" {
|
||||
item["kind"] = model.Kind
|
||||
}
|
||||
seen[id] = true
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
paths, _ := filepath.Glob("/sys/bus/usb/devices/*")
|
||||
for _, path := range paths {
|
||||
read := func(n string) string {
|
||||
b, _ := os.ReadFile(filepath.Join(path, n))
|
||||
return strings.TrimSpace(string(b))
|
||||
for _, device := range usb {
|
||||
if !seen[device.id] {
|
||||
seen[device.id] = true
|
||||
item := s.discovery(device.id, device.speed, true)
|
||||
if unsafePreparation[device.id] {
|
||||
item["preparation_safe"] = false
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if read("idVendor") != "8086" || read("idProduct") != "0b5c" {
|
||||
continue
|
||||
}
|
||||
serial := read("serial")
|
||||
if serial == "" {
|
||||
continue
|
||||
}
|
||||
h := sha256.Sum256([]byte(serial))
|
||||
id := "rsd455_" + hex.EncodeToString(h[:])[:32]
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
items = append(items, s.discovery(id, read("speed")+" Мбит/с", true))
|
||||
}
|
||||
s.mu.Lock()
|
||||
configured := []string{}
|
||||
for id, ready := range s.initialized {
|
||||
if ready && sensorID.MatchString(id) && !seen[id] {
|
||||
if model := modelForDevice(id); ready && model != nil && model.PrepareUnit != "" && !seen[id] {
|
||||
configured = append(configured, id)
|
||||
}
|
||||
}
|
||||
@@ -246,28 +268,41 @@ func (s *Sensors) Inventory() map[string]any {
|
||||
for _, id := range configured {
|
||||
items = append(items, s.discovery(id, "—", false))
|
||||
}
|
||||
|
||||
var preparation any
|
||||
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
|
||||
if data, err := os.ReadFile(sensorModels[0].Report); err == nil && len(data) < 32768 {
|
||||
_ = json.Unmarshal(data, &preparation)
|
||||
}
|
||||
s.mu.Lock()
|
||||
operations := []any{}
|
||||
preparations := []*sensorPreparation{}
|
||||
for _, v := range s.operations {
|
||||
if time.Now().Unix()-v.Updated < 600 {
|
||||
if time.Now().Unix()-v.Updated < 600 || v.State == "running" {
|
||||
operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "requested_at": v.Command.Requested, "state": v.State, "error": v.Error})
|
||||
if v.Preparation != nil {
|
||||
copy := *v.Preparation
|
||||
preparations = append(preparations, ©)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
|
||||
for i, value := range preparations {
|
||||
preparations[i] = preparationView(value)
|
||||
}
|
||||
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "preparations": preparations, "operations": operations}
|
||||
}
|
||||
func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
model := modelForDevice(id)
|
||||
s.mu.Lock()
|
||||
name, configured := s.names[id], s.initialized[id]
|
||||
identity, identityPresent := s.discoverySessions[id]
|
||||
session := s.discoverySessions[id].session
|
||||
if session == "" {
|
||||
session = s.instance + "_" + id
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if name == "" {
|
||||
name = "RealSense D455"
|
||||
name = model.Name
|
||||
}
|
||||
connectivity, enrollment := "offline", "empty"
|
||||
if online {
|
||||
@@ -276,24 +311,30 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
if configured {
|
||||
enrollment = "enrolled"
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": "RealSense D455", "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.6", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
stability, basis := "stable", "hardware-identifier"
|
||||
initializable := !identityPresent || identity.stable
|
||||
if !initializable {
|
||||
stability, basis = "provisional", "transport-local"
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": model.Name, "kind": model.Kind, "initializable": initializable, "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": session, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": model.Plugin, "plugin_version": model.Version, "model_id": model.ID}, "stability": stability, "basis": basis}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
"revision": 0, "enrollment": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
|
||||
}
|
||||
|
||||
func sensorViewAction(action string) bool {
|
||||
return action == "details" || action == "offer" || action == "close-peer"
|
||||
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list"
|
||||
}
|
||||
|
||||
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||
if strings.HasPrefix(c.Session.DeviceID, "k1_") && (c.Action == "prepare" || c.Action == "replay") {
|
||||
return nil, errors.New("Эта операция не поддерживается K1.")
|
||||
}
|
||||
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
|
||||
model := modelForDevice(c.Session.DeviceID)
|
||||
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || model == nil || c.Session.SessionID == "" || len(c.Session.SessionID) > 192 {
|
||||
return nil, errors.New("Некорректная команда устройства.")
|
||||
}
|
||||
if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "replay": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] {
|
||||
return nil, errors.New("Операция не поддерживается.")
|
||||
if !model.Actions[c.Action] {
|
||||
return nil, errors.New("Операция не поддерживается этой моделью.")
|
||||
}
|
||||
if c.Action == "prepare" && len(c.Parameters) != 0 {
|
||||
return nil, errors.New("Подготовка использует встроенный профиль устройства.")
|
||||
}
|
||||
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
requested, e2 := time.Parse(time.RFC3339Nano, c.Requested)
|
||||
@@ -315,7 +356,7 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
for _, v := range s.operations {
|
||||
if v.State == "running" && v.Command.Action == "prepare" {
|
||||
if v.State == "running" && v.Command.Action == "prepare" && (v.Preparation == nil || v.Preparation.Phase == "profile") && c.Action != "prepare" && !sensorViewAction(c.Action) && modelForDevice(v.Command.Session.DeviceID) == model {
|
||||
return nil, errors.New("Подготовка модели ещё выполняется.")
|
||||
}
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
@@ -357,10 +398,20 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if item == nil {
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
if !deadline.After(time.Now()) {
|
||||
err = errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
} else if item == nil {
|
||||
err = errors.New("Камера не обнаружена. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["online"] != true {
|
||||
err = errors.New("Камера отключена. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["initializable"] == false {
|
||||
err = errors.New("Не удалось однозначно определить камеру. Проверьте её идентификатор и подключение.")
|
||||
} else if (c.Action == "prepare" || c.Action == "rename") && sensorSessionID(item) != c.Session.SessionID {
|
||||
err = errors.New("Сеанс устройства изменился. Обновите сведения.")
|
||||
} else if c.Action == "prepare" {
|
||||
result, err = s.prepare(c)
|
||||
result, err = s.prepare(c, item)
|
||||
uncertain = errors.Is(err, errPreparationUncertain)
|
||||
} else if c.Action == "rename" {
|
||||
name, ok := c.Parameters["name"].(string)
|
||||
if !ok || strings.TrimSpace(name) == "" || len([]rune(name)) > 80 || strings.ContainsAny(name, "\n\r\t") {
|
||||
@@ -374,14 +425,10 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
}
|
||||
} else {
|
||||
var v map[string]any
|
||||
if strings.HasPrefix(c.Session.DeviceID, "k1_") && s.NetworkDevices != nil {
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
v, err = s.NetworkDevices.call(ctx, "/sensor-operation", c)
|
||||
cancel()
|
||||
} else {
|
||||
v, err = s.driver("/operation", c)
|
||||
}
|
||||
deadline, _ := time.Parse(time.RFC3339Nano, c.Deadline)
|
||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||
v, err = s.modelDriver(ctx, modelForDevice(c.Session.DeviceID), "/operation", c)
|
||||
cancel()
|
||||
uncertain = err != nil || v["state"] == "unknown"
|
||||
if err == nil {
|
||||
if v["state"] == "complete" {
|
||||
@@ -418,48 +465,6 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
v.Error = "Не удалось сохранить результат операции. Обновите состояние устройства."
|
||||
}
|
||||
}
|
||||
func (s *Sensors) prepare(c SensorCommand) (any, error) {
|
||||
s.prepareMu.Lock()
|
||||
defer s.prepareMu.Unlock()
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
snap := item["snapshot"].(map[string]any)
|
||||
if state := snap["acquisition"]; state != "idle" && state != "failed" {
|
||||
return nil, errors.New("Остановите захват камер перед подготовкой модели.")
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
|
||||
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"}
|
||||
if cmd.Run() != nil {
|
||||
return nil, errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.")
|
||||
}
|
||||
for i := 0; i < 12; i++ {
|
||||
inv := s.Inventory()
|
||||
for _, v := range inv["items"].([]any) {
|
||||
item := v.(map[string]any)
|
||||
if item["id"] == c.Session.DeviceID && item["prepared"] == true {
|
||||
snap := item["snapshot"].(map[string]any)
|
||||
sc := snap["context"].(map[string]any)
|
||||
verify := c
|
||||
verify.Action = "verify"
|
||||
verify.Session.SessionID = sc["session_id"].(string)
|
||||
result, e := s.driver("/operation", verify)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if result["state"] != "complete" {
|
||||
message, _ := result["error"].(string)
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
return result["result"], nil
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
|
||||
}
|
||||
func (s *Sensors) Get(id string) *SensorOperation {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -9,6 +9,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Synthetic tests own both USB discovery and every model transport.
|
||||
func isolateSensorHost(t *testing.T, s *Sensors) {
|
||||
t.Helper()
|
||||
s.usbRoot = t.TempDir()
|
||||
for _, client := range s.clients {
|
||||
client.Transport = sensorRoundTrip(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"items":[]}`)), Header: http.Header{}}, nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sensorTestCommand() SensorCommand {
|
||||
now := time.Now()
|
||||
id := "op_01234567890123456789012345678901"
|
||||
@@ -16,6 +27,7 @@ func sensorTestCommand() SensorCommand {
|
||||
}
|
||||
func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T) {
|
||||
s, e := OpenSensors(t.TempDir(), "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
@@ -39,12 +51,16 @@ func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T) {
|
||||
func TestSensorUncertainCrashDoesNotReplay(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, _ := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
c := sensorTestCommand()
|
||||
old := &SensorOperation{Command: c, State: "running", Updated: time.Now().Unix()}
|
||||
if e := s.write(c.ID+".json", old); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
@@ -65,6 +81,7 @@ func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
|
||||
for _, response := range []string{`{"state":"unknown","error":"uncertain"}`, "transport-failure"} {
|
||||
t.Run(response, func(t *testing.T) {
|
||||
s, _ := OpenSensors(t.TempDir(), "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
c := sensorTestCommand()
|
||||
s.operations[c.ID] = &SensorOperation{Command: c, State: "running"}
|
||||
s.client = &http.Client{Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
@@ -88,12 +105,16 @@ func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
|
||||
func TestSensorConfiguredIdentitySurvivesRestartAndDisconnect(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s, _ := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
c := sensorTestCommand()
|
||||
s.initialized[c.Session.DeviceID] = true
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := OpenSensors(root, "node_test")
|
||||
isolateSensorHost(t, s)
|
||||
isolateSensorHost(t, s)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// An authenticated Node action may start only this fixed model job.
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && action.lookup("unit") === "mission-core-node-realsense-prepare.service" && action.lookup("verb") === "start") {
|
||||
var unit = action.lookup("unit");
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from build_deb import build, VERSION, BRAND_SHA256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DG_COMMIT = "b10fd5d645ddfb8c373ae6105efa0850aef2509c"
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
|
||||
|
||||
def guideline_sources():
|
||||
|
||||
@@ -11,7 +11,8 @@ import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VERSION = "0.8.16"
|
||||
BINARY_VERSION = "0.8.19"
|
||||
VERSION = "0.8.19"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
from debian import package
|
||||
|
||||
@@ -43,7 +44,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615)
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
""".encode()
|
||||
@@ -80,6 +81,21 @@ Description: Mission Core onboard computer configuration
|
||||
for name in ("mission-core-realsense.service", "mission-core-node-realsense-prepare.service"):
|
||||
files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644))
|
||||
files.append(("usr/share/polkit-1/rules.d/50-mission-core-device-prepare.rules", (p / "50-mission-core-device-prepare.rules").read_bytes(), 0o644))
|
||||
# Node owns the first-use bootstrap; the optional model package owns its
|
||||
# SDK, worker processes, USB grants and runtime preparation. No overlapping
|
||||
# dpkg file ownership and no compiler/download prerequisite at first use.
|
||||
profile_raw = (p / "insta360-profile.json").read_bytes()
|
||||
profile = json.loads(profile_raw)
|
||||
package_name = "mission-core-insta360-x4_" + profile["version"] + "_amd64.deb"
|
||||
x4 = (ROOT / "build/model-packages" / package_name).read_bytes()
|
||||
if len(x4) != profile["bytes"] or hashlib.sha256(x4).hexdigest() != profile["sha256"]:
|
||||
raise ValueError("Bundled X4 package differs from the admitted release")
|
||||
files.extend([
|
||||
("usr/lib/mission-core-node/insta360_profile.py", (p / "insta360_profile.py").read_bytes(), 0o644),
|
||||
("usr/lib/systemd/system/mission-core-node-insta360-x4-profile.service", (p / "mission-core-node-insta360-x4-profile.service").read_bytes(), 0o644),
|
||||
("usr/share/mission-core-node/profiles/insta360-x4/profile.json", profile_raw, 0o644),
|
||||
("usr/share/mission-core-node/profiles/insta360-x4/" + package_name, x4, 0o644),
|
||||
])
|
||||
files.append(("usr/share/mission-core-node/realsense/70-mission-core-realsense.rules", (p / "70-mission-core-realsense.rules").read_bytes(), 0o644))
|
||||
bundle = json.loads((p / "realsense-bundle.json").read_text())
|
||||
files.append(("usr/share/mission-core-node/realsense/bundle.json", (p / "realsense-bundle.json").read_bytes(), 0o644))
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Snapshot the reviewed Node/Core sources and Linux build inputs for the Mini."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
REPO = NODE.parents[1]
|
||||
DG = REPO.parent / "NODEDC_DESIGN_GUIDELINE"
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
|
||||
|
||||
def files(root):
|
||||
values = (
|
||||
subprocess.check_output(
|
||||
["git", "ls-files", "-c", "-o", "--exclude-standard", "-z"], cwd=root
|
||||
)
|
||||
.decode()
|
||||
.split("\0")
|
||||
)
|
||||
for name in sorted(set(values)):
|
||||
path = root / name
|
||||
if not name or name.startswith(".codex/") or not path.is_file():
|
||||
continue
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root):
|
||||
raise ValueError("Unexpected source link")
|
||||
yield path
|
||||
|
||||
|
||||
def build(qualified, node_only=False):
|
||||
commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=DG, text=True).strip()
|
||||
if commit != DG_COMMIT:
|
||||
raise ValueError("Design Guideline revision differs from the admitted source")
|
||||
paths = list(files(REPO))
|
||||
# Build DG from its own pinned workspace lock. Its generated dist and
|
||||
# transitive dependencies are not copied from a developer installation.
|
||||
paths += [
|
||||
path
|
||||
for path in files(DG)
|
||||
if path.relative_to(DG).parts[0]
|
||||
in ("packages", "registry", "docs", "scripts", "server", "apps")
|
||||
]
|
||||
paths += [
|
||||
DG / name
|
||||
for name in (
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.base.json",
|
||||
"apps/catalog/package.json",
|
||||
)
|
||||
]
|
||||
lock = json.loads((NODE / "packaging/linux-toolchains.json").read_text())
|
||||
paths += [NODE / "build/linux-toolchains" / item["name"] for item in lock["files"]]
|
||||
for item in lock["files"]:
|
||||
data = (NODE / "build/linux-toolchains" / item["name"]).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||
raise ValueError("Build toolchain changed")
|
||||
wheel_lock = json.loads((NODE / "packaging/realsense-bundle.json").read_text())
|
||||
paths += [NODE / "build/realsense-wheels" / item["name"] for item in wheel_lock["wheels"]]
|
||||
profile = json.loads((NODE / "packaging/insta360-profile.json").read_text())
|
||||
package_name = "mission-core-insta360-x4_" + profile["version"] + "_amd64.deb"
|
||||
if qualified.name != package_name:
|
||||
raise ValueError("Unexpected model package name")
|
||||
package = qualified.read_bytes()
|
||||
if len(package) != profile["bytes"] or hashlib.sha256(package).hexdigest() != profile["sha256"]:
|
||||
raise ValueError("Qualified model package changed")
|
||||
entries = {str(path.relative_to(REPO.parent)): path for path in sorted(set(paths))}
|
||||
# The source artifact owns this build staging copy; no manual copy on the
|
||||
# board, runtime path override or operator compiler dependency is needed.
|
||||
virtual = str(REPO.name + "/apps/node-agent/build/model-packages/" + package_name)
|
||||
entries[virtual] = qualified
|
||||
metadata = {}
|
||||
for name, path in entries.items():
|
||||
data = path.read_bytes()
|
||||
if len(data) > 100 * 1024 * 1024:
|
||||
raise ValueError("Source file exceeds the artifact bound")
|
||||
metadata[name] = {
|
||||
"bytes": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"mode": 0o755 if path.stat().st_mode & stat.S_IXUSR else 0o644,
|
||||
}
|
||||
entrypoint = (NODE / "packaging/linux_build_entry.py").read_bytes()
|
||||
manifest = {
|
||||
"schema": "missioncore.node.linux-build-source/v1",
|
||||
"profile": "node-only" if node_only else "node-core",
|
||||
"repository": REPO.name,
|
||||
"base_commit": subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=REPO, text=True
|
||||
).strip(),
|
||||
"design_guideline_commit": commit,
|
||||
"toolchains": lock,
|
||||
"entrypoint_sha256": hashlib.sha256(entrypoint).hexdigest(),
|
||||
"files": metadata,
|
||||
}
|
||||
raw = json.dumps(manifest, sort_keys=True, indent=2).encode() + b"\n"
|
||||
identifier = hashlib.sha256(raw).hexdigest()[:24]
|
||||
output = NODE / "build" / ("mission-core-node-linux-build-" + identifier + ".pyz")
|
||||
with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
|
||||
for name, data in (("__main__.py", entrypoint), ("source.json", raw)):
|
||||
archive.writestr(name, data)
|
||||
for name, path in entries.items():
|
||||
archive.write(
|
||||
path,
|
||||
"source/" + name,
|
||||
compress_type=zipfile.ZIP_DEFLATED
|
||||
if path.stat().st_size < 4 * 1024 * 1024
|
||||
else zipfile.ZIP_STORED,
|
||||
)
|
||||
output.chmod(0o600)
|
||||
return {
|
||||
"artifact": str(output),
|
||||
"source_id": identifier,
|
||||
"files": len(entries),
|
||||
"bytes": output.stat().st_size,
|
||||
"sha256": hashlib.sha256(output.read_bytes()).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--model-package", type=Path, required=True)
|
||||
parser.add_argument("--node-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(build(args.model_package.resolve(), args.node_only)))
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Package the Ubuntu-qualified Node installer with the shared release launcher."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPO = ROOT.parents[1]
|
||||
|
||||
|
||||
def build(qualified):
|
||||
evidence = json.loads((qualified / "qualification.json").read_text())
|
||||
if evidence["state"] != "complete":
|
||||
raise ValueError("A completed Ubuntu qualification is required")
|
||||
candidates = [name for name in evidence["artifacts"] if name.startswith("mission-core-node_")]
|
||||
if len(candidates) != 1:
|
||||
raise ValueError("One qualified Node package is required")
|
||||
package = candidates[0]
|
||||
match = re.fullmatch(
|
||||
r"mission-core-node_([0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?)_amd64\.deb", package
|
||||
)
|
||||
if not match:
|
||||
raise ValueError("Invalid package name")
|
||||
payload = (qualified / package).read_bytes()
|
||||
expected = evidence["artifacts"][package]
|
||||
if (
|
||||
len(payload) != expected["bytes"]
|
||||
or hashlib.sha256(payload).hexdigest() != expected["sha256"]
|
||||
):
|
||||
raise ValueError("Qualified Node package changed")
|
||||
files = {
|
||||
package: payload,
|
||||
"install": (ROOT / "packaging/install-owner-release").read_bytes(),
|
||||
"install_release.py": (ROOT / "packaging/install_owner_release.py").read_bytes(),
|
||||
}
|
||||
entry = (REPO / "plugins/insta360-x4/packaging/owner_release_entry.py").read_bytes()
|
||||
manifest = {
|
||||
"schema": "missioncore.node.owner-release/v1",
|
||||
"version": match[1],
|
||||
"qualification_profile": "node-package-and-local-ui",
|
||||
"qualification_sha256": hashlib.sha256(
|
||||
(qualified / "qualification.json").read_bytes()
|
||||
).hexdigest(),
|
||||
"clean_image_qualified": False,
|
||||
"entrypoint_sha256": hashlib.sha256(entry).hexdigest(),
|
||||
"files": {
|
||||
name: {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
|
||||
for name, data in files.items()
|
||||
},
|
||||
}
|
||||
raw = json.dumps(manifest, indent=2, sort_keys=True).encode() + b"\n"
|
||||
identifier = hashlib.sha256(raw).hexdigest()[:24]
|
||||
output = ROOT / "build" / ("mission-core-node-install-" + identifier + ".pyz")
|
||||
with output.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
|
||||
for name, data in {"__main__.py": entry, "release.json": raw, **files}.items():
|
||||
archive.writestr(name, data)
|
||||
output.chmod(0o600)
|
||||
return {
|
||||
"artifact": str(output),
|
||||
"release_id": identifier,
|
||||
"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)))
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Versioned Python/Go X509 interoperability check; synthetic public certificates only."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
GO_SOURCE = r"""package main
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
)
|
||||
func cert(name string) *x509.Certificate {
|
||||
raw, err := os.ReadFile(name); if err != nil { panic(err) }
|
||||
block, _ := pem.Decode(raw); if block == nil { panic("invalid fixture") }
|
||||
value, err := x509.ParseCertificate(block.Bytes); if err != nil { panic(err) }; return value
|
||||
}
|
||||
func main() {
|
||||
root := cert("root.pem"); bad := cert("legacy.pem"); good := cert("fixed.pem")
|
||||
roots := x509.NewCertPool(); roots.AddCert(root)
|
||||
options := x509.VerifyOptions{
|
||||
Roots: roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
_, legacyErr := bad.Verify(options)
|
||||
if _, ok := legacyErr.(x509.UnknownAuthorityError); !ok { panic("legacy failure not reproduced") }
|
||||
if _, err := good.Verify(options); err != nil { panic(err) }
|
||||
if string(good.RawSubjectPublicKeyInfo) != string(root.RawSubjectPublicKeyInfo) {
|
||||
panic("Core key changed")
|
||||
}
|
||||
if string(good.RawSubject) == string(root.RawSubject) { panic("ambiguous leaf subject") }
|
||||
json.NewEncoder(os.Stdout).Encode(map[string]any{"legacy_rejected":true,"fixed_verified":true,"core_key_preserved":true})
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def build():
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import ExtendedKeyUsageOID
|
||||
|
||||
from k1link.fleet.recovery import client_context
|
||||
from k1link.fleet.trust import CoreTrust, pem
|
||||
|
||||
node = Path(__file__).resolve().parents[1]
|
||||
lock = json.loads((node / "packaging/linux-toolchains.json").read_text())
|
||||
go = next(item for item in lock["files"] if item["directory"] == "go")
|
||||
toolchain = (node / "build/linux-toolchains" / go["name"]).read_bytes()
|
||||
if digest(toolchain) != go["sha256"]:
|
||||
raise ValueError("Pinned toolchain changed")
|
||||
with tempfile.TemporaryDirectory(prefix="missioncore-synthetic-ca-") as temporary:
|
||||
trust = CoreTrust(Path(temporary))
|
||||
client_context(trust)
|
||||
fixed = x509.load_pem_x509_certificates(
|
||||
(Path(temporary) / "recovery-client.pem").read_bytes()
|
||||
)[0]
|
||||
legacy = (
|
||||
trust.builder(trust.ca.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)
|
||||
)
|
||||
files = {
|
||||
"root.pem": pem(trust.ca).encode(),
|
||||
"fixed.pem": pem(fixed).encode(),
|
||||
"legacy.pem": pem(legacy).encode(),
|
||||
"check.go": GO_SOURCE.encode(),
|
||||
"go.tar.gz": toolchain,
|
||||
}
|
||||
entry = Path(__file__).read_bytes()
|
||||
manifest = {
|
||||
"schema": "missioncore.channel-x509-check/v1",
|
||||
"toolchain": go,
|
||||
"entry_sha256": digest(entry),
|
||||
"files": {
|
||||
name: {"bytes": len(data), "sha256": digest(data)} for name, data in files.items()
|
||||
},
|
||||
}
|
||||
raw = json.dumps(manifest, sort_keys=True).encode()
|
||||
identifier = digest(raw)[:24]
|
||||
path = node / "build" / ("mission-core-channel-check-" + identifier + ".pyz")
|
||||
with path.open("xb") as stream, zipfile.ZipFile(stream, "w") as archive:
|
||||
for name, data in {"__main__.py": entry, "check.json": raw, **files}.items():
|
||||
archive.writestr(name, data)
|
||||
path.chmod(0o600)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"artifact": str(path),
|
||||
"id": identifier,
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": digest(path.read_bytes()),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def run():
|
||||
if os.geteuid() == 0 or (platform.system(), platform.machine()) != ("Linux", "x86_64"):
|
||||
raise ValueError("Use the unprivileged Ubuntu account")
|
||||
if sys.argv[1:] not in ([], ["--job"], ["--clean"]):
|
||||
raise ValueError("Only the fixed certificate check is allowed")
|
||||
os.umask(0o077)
|
||||
artifact = Path(sys.argv[0]).resolve()
|
||||
with zipfile.ZipFile(artifact) as archive:
|
||||
raw = archive.read("check.json")
|
||||
manifest = json.loads(raw)
|
||||
names = {"root.pem", "legacy.pem", "fixed.pem", "check.go", "go.tar.gz"}
|
||||
if (
|
||||
manifest["schema"] != "missioncore.channel-x509-check/v1"
|
||||
or set(manifest["files"]) != names
|
||||
or set(archive.namelist()) != names | {"__main__.py", "check.json"}
|
||||
or len(archive.namelist()) != 7
|
||||
or digest(archive.read("__main__.py")) != manifest["entry_sha256"]
|
||||
):
|
||||
raise ValueError("Unexpected check artifact")
|
||||
identifier = digest(raw)[:24]
|
||||
root = Path("/var/tmp/mission-core-channel-checks")
|
||||
folder = root / identifier
|
||||
for path in (root, folder):
|
||||
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 ValueError("Check directory is not private and owned")
|
||||
if sys.argv[1:] == ["--clean"]:
|
||||
if not (folder / "report.json").exists():
|
||||
raise ValueError("Preserve a completed attempt before cleanup")
|
||||
shutil.rmtree(folder)
|
||||
print(json.dumps({"cleaned": identifier}))
|
||||
return
|
||||
if not sys.argv[1:]:
|
||||
if any(folder.iterdir()):
|
||||
raise ValueError("Preserve the prior attempt before retry")
|
||||
for name, expected in manifest["files"].items():
|
||||
data = archive.read(name)
|
||||
if len(data) != expected["bytes"] or digest(data) != expected["sha256"]:
|
||||
raise ValueError("Check input changed")
|
||||
(folder / name).write_bytes(data)
|
||||
(folder / "report.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": "staging",
|
||||
"id": identifier,
|
||||
"started_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
with tarfile.open(folder / "go.tar.gz") as tar:
|
||||
members = tar.getmembers()
|
||||
if len(members) > 40000 or sum(m.size for m in members) > 1024**3:
|
||||
raise ValueError("Toolchain extraction exceeds bound")
|
||||
if any(
|
||||
Path(m.name).is_absolute()
|
||||
or Path(m.name).parts[0] != "go"
|
||||
or ".." in Path(m.name).parts
|
||||
for m in members
|
||||
):
|
||||
raise ValueError("Unexpected toolchain path")
|
||||
tar.extractall(folder, members=members, filter="data")
|
||||
subprocess.run(
|
||||
[
|
||||
"/usr/bin/systemd-run",
|
||||
"--user",
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--wait",
|
||||
"--pipe",
|
||||
"--unit=mission-core-channel-check-" + identifier,
|
||||
"--property=MemoryMax=1G",
|
||||
"--property=CPUQuota=150%",
|
||||
"--property=TasksMax=128",
|
||||
"--property=RuntimeMaxSec=180",
|
||||
"--property=KillMode=control-group",
|
||||
"--property=Nice=10",
|
||||
"/usr/bin/python3",
|
||||
str(artifact),
|
||||
"--job",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
print((folder / "report.json").read_text())
|
||||
return
|
||||
group = next(
|
||||
line.split(":", 2)[2]
|
||||
for line in Path("/proc/self/cgroup").read_text().splitlines()
|
||||
if line.startswith("0::")
|
||||
)
|
||||
control = Path("/sys/fs/cgroup") / group.lstrip("/")
|
||||
quota, period = map(int, (control / "cpu.max").read_text().split())
|
||||
if (
|
||||
int((control / "memory.max").read_text()) > 1024**3
|
||||
or quota / period > 1.5
|
||||
or int((control / "pids.max").read_text()) > 128
|
||||
):
|
||||
raise ValueError("Check limits not enforced")
|
||||
started = time.monotonic()
|
||||
report = {
|
||||
"schema": manifest["schema"],
|
||||
"id": identifier,
|
||||
"started_at": datetime.now(UTC).isoformat(),
|
||||
"monotonic": started,
|
||||
"artifact_sha256": digest(artifact.read_bytes()),
|
||||
}
|
||||
env = {
|
||||
"PATH": str(folder / "go/bin") + ":/usr/bin:/bin",
|
||||
"GOENV": "off",
|
||||
"GOCACHE": str(folder / "cache"),
|
||||
"GOPATH": str(folder / "gopath"),
|
||||
"GOTOOLCHAIN": "local",
|
||||
"GOPROXY": "off",
|
||||
"GOMAXPROCS": "2",
|
||||
"GOMEMLIMIT": "512MiB",
|
||||
"CGO_ENABLED": "0",
|
||||
"GOWORK": "off",
|
||||
}
|
||||
# No SDK, USB, installed files, network fetches, or real host credentials.
|
||||
result = subprocess.run(
|
||||
[str(folder / "go/bin/go"), "run", "-p", "1", "check.go"],
|
||||
cwd=folder,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=150,
|
||||
)
|
||||
(folder / "stdout").write_bytes(result.stdout)
|
||||
(folder / "stderr").write_bytes(result.stderr)
|
||||
report.update(
|
||||
state="complete" if result.returncode == 0 else "error",
|
||||
exit_code=result.returncode,
|
||||
duration_seconds=time.monotonic() - started,
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
stdout_sha256=digest(result.stdout),
|
||||
stderr_sha256=digest(result.stderr),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
report["checks"] = json.loads(result.stdout)
|
||||
(folder / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
if result.returncode:
|
||||
raise RuntimeError("Certificate interop check failed; inspect private report")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:] == ["--build"]:
|
||||
build()
|
||||
else:
|
||||
run()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Run only synthetic X4 bootstrap checks in disposable unprivileged Ubuntu staging."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
FILES = {
|
||||
"apps/node-agent/packaging/insta360_profile.py",
|
||||
"plugins/insta360-x4/tests/check_profile.py",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() == 0 or platform.freedesktop_os_release().get("VERSION_ID") != "24.04":
|
||||
raise ValueError("Run as an ordinary Ubuntu 24.04 user")
|
||||
started, monotonic = datetime.now(UTC).isoformat(), time.monotonic()
|
||||
with zipfile.ZipFile(sys.argv[0]) as archive:
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
if set(archive.namelist()) != FILES | {"__main__.py", "manifest.json"} or set(
|
||||
manifest
|
||||
) != FILES | {"__main__.py"}:
|
||||
raise ValueError("Unexpected check artifact")
|
||||
payload = {name: archive.read(name) for name in manifest}
|
||||
if any(hashlib.sha256(raw).hexdigest() != manifest[name] for name, raw in payload.items()):
|
||||
raise ValueError("Check artifact changed")
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="mission-core-x4-profile-check-", dir="/var/tmp"
|
||||
) as folder:
|
||||
root = Path(folder)
|
||||
for name in FILES:
|
||||
target = root / name
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target.write_bytes(payload[name])
|
||||
(root / "plugins/insta360-x4/build").mkdir(mode=0o700)
|
||||
result = subprocess.run(
|
||||
["/usr/bin/python3", "-I", str(root / "plugins/insta360-x4/tests/check_profile.py")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "missioncore.node.x4-profile-check/v1",
|
||||
"started_at": started,
|
||||
"monotonic_started": monotonic,
|
||||
"duration_seconds": time.monotonic() - monotonic,
|
||||
"state": "complete" if result.returncode == 0 else "error",
|
||||
"files": manifest,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
)
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,138 @@
|
||||
"""D01: display only the latest failed X4 installer logs in an owner sudo TTY.
|
||||
|
||||
No package installation, service operation, camera command or permission change.
|
||||
The owner terminal records stdout in a private user-owned diagnostic directory.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
STATE = Path("/var/lib/mission-core-node-profiles/insta360-x4")
|
||||
|
||||
|
||||
def read_root(path, limit):
|
||||
for parent in reversed(path.parents):
|
||||
info = parent.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_uid or info.st_mode & 0o022:
|
||||
raise ValueError("Untrusted diagnostic path")
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid or info.st_mode & 0o022:
|
||||
raise ValueError("Untrusted diagnostic file")
|
||||
data = os.read(fd, limit + 1)
|
||||
if len(data) > limit:
|
||||
raise ValueError("Diagnostic file exceeds bound")
|
||||
return data
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def report():
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("Use the owner terminal")
|
||||
state = json.loads(read_root(STATE / "preparation.json", 65536))
|
||||
run_id = state.get("run_id", "")
|
||||
if state.get("state") != "error" or not re.fullmatch("[0-9a-f]{32}", run_id):
|
||||
raise ValueError("No completed failed X4 profile")
|
||||
logs = {}
|
||||
for name in ("1.stdout", "1.stderr"):
|
||||
data = read_root(STATE / run_id / name, 1024 * 1024)
|
||||
logs[name] = {
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"text": data.decode(errors="replace"),
|
||||
}
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema": "missioncore.node.x4-profile-diagnostic/v1",
|
||||
"utc": datetime.now(UTC).isoformat(),
|
||||
"monotonic": time.monotonic(),
|
||||
"profile": state,
|
||||
"logs": logs,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def launch():
|
||||
if os.geteuid() == 0:
|
||||
raise ValueError("Launch as the desktop owner")
|
||||
source = Path(__file__).resolve()
|
||||
if not re.fullmatch(r"/var/tmp/mission-core-x4-diagnostic-[0-9a-f]{24}\.py", str(source)):
|
||||
raise ValueError("Use the versioned diagnostic artifact")
|
||||
raw = source.read_bytes()
|
||||
if source.stem != "mission-core-x4-diagnostic-" + hashlib.sha256(raw).hexdigest()[:24]:
|
||||
raise ValueError("Diagnostic artifact changed")
|
||||
os.umask(0o077)
|
||||
folder = source.with_suffix("")
|
||||
folder.mkdir(mode=0o700, exist_ok=True)
|
||||
info = folder.lstat()
|
||||
if folder.is_symlink() or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
||||
raise ValueError("Diagnostic directory must be private")
|
||||
script = folder / "read-report"
|
||||
script.write_text(
|
||||
"#!/bin/bash\nset -uo pipefail\numask 077\nprintf '%s\\n' "
|
||||
"'Mission Core: чтение ошибки подготовки X4' "
|
||||
"'Изменений системы и команд камеры не будет. Введите пароль Ubuntu.'\n"
|
||||
"/usr/bin/sudo /usr/bin/python3 -I '"
|
||||
+ str(source)
|
||||
+ "' --read 2>&1 | /usr/bin/tee '"
|
||||
+ str(folder / "report.json")
|
||||
+ "'\nmc_x4_result=${PIPESTATUS[0]}\nprintf "
|
||||
"'\\nКод завершения: %s\\nНажмите Enter, чтобы закрыть.\\n' "
|
||||
'"$mc_x4_result"\nread -r mc_x4_close\nexit "$mc_x4_result"\n'
|
||||
)
|
||||
script.chmod(0o700)
|
||||
env = dict(os.environ)
|
||||
result = subprocess.run(
|
||||
["/usr/bin/systemctl", "--user", "show-environment"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=True,
|
||||
)
|
||||
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",
|
||||
}:
|
||||
env[key] = value
|
||||
with (folder / "launcher.log").open("ab") as output:
|
||||
subprocess.Popen(
|
||||
[
|
||||
"/usr/bin/gnome-terminal",
|
||||
"--wait",
|
||||
"--title=Mission Core · Диагностика X4",
|
||||
"--",
|
||||
str(script),
|
||||
],
|
||||
env=env,
|
||||
stdout=output,
|
||||
stderr=output,
|
||||
start_new_session=True,
|
||||
)
|
||||
print(json.dumps({"directory": str(folder), "sha256": hashlib.sha256(raw).hexdigest()}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:] == ["--read"]:
|
||||
report()
|
||||
elif sys.argv[1:] == ["--launch"]:
|
||||
launch()
|
||||
else:
|
||||
sys.exit("Use --launch or --read")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Build-only pinned toolchains; never an operator installation prerequisite."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main():
|
||||
lock = json.loads((ROOT / "packaging/linux-toolchains.json").read_text())
|
||||
output = ROOT / "build/linux-toolchains"
|
||||
output.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||
os.umask(0o077)
|
||||
for item in lock["files"]:
|
||||
target = output / item["name"]
|
||||
if not target.exists():
|
||||
with urllib.request.urlopen(item["url"], timeout=60) as response:
|
||||
data = response.read(100 * 1024 * 1024 + 1)
|
||||
if len(data) > 100 * 1024 * 1024 or hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||
raise ValueError("Official toolchain differs from the pinned release")
|
||||
with target.open("xb") as stream:
|
||||
stream.write(data)
|
||||
data = target.read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != item["sha256"]:
|
||||
raise ValueError("Cached toolchain hash mismatch")
|
||||
print(json.dumps({"file": target.name, "bytes": len(data), "sha256": item["sha256"]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema": "missioncore.node.bundled-model/v1",
|
||||
"model_id": "insta360.x4",
|
||||
"version": "0.1.3-3",
|
||||
"revision": "b291418f2a404cbdafc57431",
|
||||
"bytes": 58199970,
|
||||
"sha256": "c10f9c1e7f8237d9df202bc29483d61f27280920d76611b1aa1c4f3a72332540"
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Node-owned X4 bootstrap; the same fixed job serves local and paired Core UI.
|
||||
|
||||
This exists before the optional model package is installed. All package bytes
|
||||
and hashes come from the Node release, never from a device or a client request.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
SHARE = Path("/usr/share/mission-core-node/profiles/insta360-x4")
|
||||
STATE = Path("/var/lib/mission-core-node-profiles/insta360-x4")
|
||||
PLUGIN_STATE = Path("/var/lib/mission-core-insta360")
|
||||
UNIT = "mission-core-node-insta360-x4-prepare.service"
|
||||
PACKAGE = "mission-core-insta360-x4"
|
||||
|
||||
|
||||
def trusted(path, directory=False):
|
||||
info = path.lstat()
|
||||
if path.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise RuntimeError("Небезопасный файл профиля камеры.")
|
||||
if path.is_dir() != directory:
|
||||
raise RuntimeError("Недопустимый файл профиля камеры.")
|
||||
return path
|
||||
|
||||
|
||||
def publish(value):
|
||||
fd, name = tempfile.mkstemp(prefix=".preparation-", dir=STATE)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
os.fchmod(stream.fileno(), 0o644)
|
||||
json.dump(value, stream, ensure_ascii=False)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
target = STATE / "preparation.json"
|
||||
if target.exists() or target.is_symlink():
|
||||
trusted(target)
|
||||
os.replace(name, target)
|
||||
descriptor = os.open(STATE, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
def payload():
|
||||
for path in (SHARE.parent, SHARE):
|
||||
trusted(path, True)
|
||||
value = json.loads(trusted(SHARE / "profile.json").read_text())
|
||||
if value.get("schema") != "missioncore.node.bundled-model/v1":
|
||||
raise RuntimeError("Неизвестный профиль камеры.")
|
||||
version = value["version"]
|
||||
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version):
|
||||
raise RuntimeError("Некорректная версия профиля камеры.")
|
||||
if not re.fullmatch(r"[0-9a-f]{24}", value["revision"]):
|
||||
raise RuntimeError("Некорректная версия драйвера.")
|
||||
path = trusted(SHARE / (PACKAGE + "_" + version + "_amd64.deb"))
|
||||
data = path.read_bytes()
|
||||
if len(data) != value["bytes"] or hashlib.sha256(data).hexdigest() != value["sha256"]:
|
||||
raise RuntimeError("Встроенный пакет камеры повреждён. Переустановите Mission Core Node.")
|
||||
return value, path
|
||||
|
||||
|
||||
def prepare():
|
||||
for path in (STATE.parent, STATE):
|
||||
path.mkdir(mode=0o755, exist_ok=True)
|
||||
trusted(path, True)
|
||||
fd = os.open(STATE / "prepare.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "r+b") as lock:
|
||||
trusted(STATE / "prepare.lock")
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return prepare_locked()
|
||||
|
||||
|
||||
def prepare_locked():
|
||||
report = {
|
||||
"schema": "missioncore.node.device-preparation/v1",
|
||||
"model_id": "insta360.x4",
|
||||
"run_id": uuid.uuid4().hex,
|
||||
"started_at": time.time(),
|
||||
"monotonic_started": time.monotonic(),
|
||||
"state": "running",
|
||||
"steps": [
|
||||
{"id": key, "label": label, "state": "pending"}
|
||||
for key, label in (
|
||||
("platform", "Проверка совместимости системы"),
|
||||
("payload", "Проверка встроенного пакета камеры"),
|
||||
("package", "Установка драйвера камеры"),
|
||||
("prepare", "Подготовка камеры"),
|
||||
)
|
||||
],
|
||||
}
|
||||
env = {
|
||||
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"LANG": "C.UTF-8",
|
||||
"DEBIAN_FRONTEND": "noninteractive",
|
||||
}
|
||||
evidence = STATE / report["run_id"]
|
||||
evidence.mkdir(mode=0o700)
|
||||
trusted(evidence, True)
|
||||
sequence = 0
|
||||
|
||||
def run(command, timeout=60):
|
||||
nonlocal sequence
|
||||
sequence += 1
|
||||
result = subprocess.run(command, capture_output=True, env=env, timeout=timeout)
|
||||
for suffix, data in (("stdout", result.stdout), ("stderr", result.stderr)):
|
||||
path = evidence / (str(sequence) + "." + suffix)
|
||||
with path.open("xb") as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
stream.write(data)
|
||||
if result.returncode:
|
||||
raise RuntimeError(
|
||||
"Этап установки камеры не завершён. Повторите подготовку устройства."
|
||||
)
|
||||
return result.stdout.decode().strip()
|
||||
|
||||
publish(report)
|
||||
try:
|
||||
for step in report["steps"]:
|
||||
step["state"] = "running"
|
||||
publish(report)
|
||||
if step["id"] == "platform":
|
||||
release = platform.freedesktop_os_release()
|
||||
if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != (
|
||||
"ubuntu",
|
||||
"24.04",
|
||||
"x86_64",
|
||||
):
|
||||
raise RuntimeError("Этот профиль поддерживает Ubuntu 24.04 amd64.")
|
||||
elif step["id"] == "payload":
|
||||
bundle, path = payload()
|
||||
report.update(revision=bundle["revision"], package_sha256=bundle["sha256"])
|
||||
elif step["id"] == "package":
|
||||
result = subprocess.run(
|
||||
["/usr/bin/dpkg-query", "-W", "-f", "${Version}\t${Status}", PACKAGE],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
previous = result.stdout.strip().split("\t") if result.returncode == 0 else []
|
||||
if (
|
||||
previous
|
||||
and subprocess.run(
|
||||
[
|
||||
"/usr/bin/dpkg",
|
||||
"--compare-versions",
|
||||
previous[0],
|
||||
"gt",
|
||||
bundle["version"],
|
||||
],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
).returncode
|
||||
== 0
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Установлен более новый драйвер X4. Обновите Mission Core Node."
|
||||
)
|
||||
if previous != [bundle["version"], "install ok installed"]:
|
||||
# Node already declares every OS dependency. Install only
|
||||
# the hash-verified local archive: APT --no-download drops
|
||||
# its local-file acquisition path on Ubuntu 24.04. dpkg
|
||||
# retains dependency and package-lock checks, without a
|
||||
# network acquisition or changes to unrelated packages.
|
||||
# Model preinst/prerm retain recording/preview safety.
|
||||
run(
|
||||
[
|
||||
"/usr/bin/dpkg",
|
||||
"--install",
|
||||
str(path),
|
||||
],
|
||||
timeout=None,
|
||||
)
|
||||
elif step["id"] == "prepare":
|
||||
current = PLUGIN_STATE / "preparation.json"
|
||||
# Package postinst prepares an existing installation during an
|
||||
# upgrade. Avoid a second preparation while its SDK connects.
|
||||
value = json.loads(trusted(current).read_text()) if current.exists() else {}
|
||||
same_upgrade = (
|
||||
previous != [bundle["version"], "install ok installed"]
|
||||
and value.get("started_at", 0) >= report["started_at"]
|
||||
and value.get("state") == "complete"
|
||||
and value.get("revision") == bundle["revision"]
|
||||
)
|
||||
if not same_upgrade:
|
||||
run(["/usr/bin/systemctl", "start", UNIT], timeout=200)
|
||||
value = json.loads(trusted(current).read_text())
|
||||
if value.get("state") != "complete" or value.get("revision") != bundle["revision"]:
|
||||
raise RuntimeError("Подготовка драйвера камеры не подтверждена.")
|
||||
step["state"] = "complete"
|
||||
publish(report)
|
||||
report["state"] = "complete"
|
||||
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
report["state"] = "error"
|
||||
report["message"] = (
|
||||
str(error)[:300]
|
||||
if isinstance(error, RuntimeError)
|
||||
else "Не удалось подготовить камеру."
|
||||
)
|
||||
for step in report["steps"]:
|
||||
if step["state"] == "running":
|
||||
step.update(state="error", message=report["message"])
|
||||
elif step["state"] == "pending":
|
||||
step["state"] = "blocked"
|
||||
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||
publish(report)
|
||||
return report["state"] == "complete"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.geteuid() or sys.argv[1:]:
|
||||
sys.exit(1)
|
||||
os.umask(0o022)
|
||||
sys.exit(0 if prepare() else 1)
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
mc_node_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
if [ ! -t 0 ]; then
|
||||
exec /usr/bin/gnome-terminal --wait --title="Mission Core Node" -- "$mc_node_release_dir/install"
|
||||
fi
|
||||
printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных пакетов камер на этом Ubuntu-компьютере.' 'Подготовка X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
set +e
|
||||
/usr/bin/sudo /usr/bin/python3 -I "$mc_node_release_dir/install_release.py" 2>&1 | /usr/bin/tee -a "$mc_node_release_dir/install-output.log"
|
||||
mc_node_install_result=${PIPESTATUS[0]}
|
||||
printf '\nКод завершения: %s\nНажмите Enter, чтобы закрыть окно.\n' "$mc_node_install_result"
|
||||
read -r mc_node_close
|
||||
exit "$mc_node_install_result"
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Fixed Node release installation, authenticated only in the owner's Ubuntu TTY."""
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pwd
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path("/var/tmp/mission-core-node-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 ValueError("Installer evidence directory is not private and root-owned")
|
||||
|
||||
|
||||
def finish_legacy_monitor_pager():
|
||||
"""Close only the known 0.8.17 postinst pager; never signal apt/dpkg/SQL."""
|
||||
query = ["/usr/bin/dpkg-query", "-W", "-f", "${Version} ${Status}", "mission-core-node"]
|
||||
state = subprocess.run(query, capture_output=True, text=True, timeout=5).stdout.strip()
|
||||
if state != "0.8.17 install ok half-configured":
|
||||
return {"required": False}
|
||||
monitor_uid = pwd.getpwnam("mission-core-monitor").pw_uid
|
||||
processes = {}
|
||||
for path in Path("/proc").glob("[0-9]*"):
|
||||
try:
|
||||
fields = (path / "stat").read_text().split(") ", 1)[1].split()
|
||||
args = (path / "cmdline").read_bytes().rstrip(b"\0").decode().split("\0")
|
||||
processes[int(path.name)] = {
|
||||
"parent": int(fields[1]),
|
||||
"start": fields[19],
|
||||
"args": args,
|
||||
"uid": path.stat().st_uid,
|
||||
}
|
||||
except (OSError, ValueError, IndexError, UnicodeError):
|
||||
continue
|
||||
|
||||
def parent(row):
|
||||
return processes.get(row.get("parent"), {})
|
||||
|
||||
selected = []
|
||||
for pid, row in processes.items():
|
||||
if row["args"] != ["pager"] or row["uid"] != monitor_uid:
|
||||
continue
|
||||
shell = parent(row)
|
||||
sql, user = parent(shell), parent(parent(shell))
|
||||
setup, post = parent(user), parent(parent(user))
|
||||
dpkg, apt = parent(post), parent(parent(post))
|
||||
if (
|
||||
shell.get("args") != ["sh", "-c", "--", "pager"]
|
||||
or sql.get("args")
|
||||
!= [
|
||||
"/usr/lib/postgresql/16/bin/psql",
|
||||
"-X",
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-h",
|
||||
"/run/mission-core-monitor-db",
|
||||
"-p",
|
||||
"5433",
|
||||
"-d",
|
||||
"mission_core_monitor",
|
||||
"-f",
|
||||
"/usr/lib/mission-core-node/monitor/schema.sql",
|
||||
]
|
||||
or setup.get("args") != ["/bin/sh", "/usr/lib/mission-core-node/setup-monitor"]
|
||||
or post.get("args", [])[:3]
|
||||
!= ["/bin/sh", "/var/lib/dpkg/info/mission-core-node.postinst", "configure"]
|
||||
or dpkg.get("args", [""])[0] != "/usr/bin/dpkg"
|
||||
or apt.get("args", [])[:4] != ["/usr/bin/apt-get", "install", "-y", "--no-remove"]
|
||||
or len(apt["args"]) != 5
|
||||
or not re.fullmatch(
|
||||
r"/var/tmp/mission-core-node-installs/[0-9a-f]{32}/"
|
||||
r"mission-core-node_0\.8\.17_amd64\.deb",
|
||||
apt["args"][4],
|
||||
)
|
||||
):
|
||||
continue
|
||||
old_package = Path(apt["args"][4])
|
||||
if hashlib.sha256(old_package.read_bytes()).hexdigest() != (
|
||||
"f929c440e4965f728aa6cd00eb122e7cad09e5ade82da763cc36661f50ec66b5"
|
||||
):
|
||||
continue
|
||||
selected.append((pid, row["start"]))
|
||||
if len(selected) != 1:
|
||||
raise RuntimeError("Не подтверждён известный просмотрщик старого установщика.")
|
||||
pid, started = selected[0]
|
||||
descriptor = os.pidfd_open(pid)
|
||||
try:
|
||||
current = (Path("/proc") / str(pid) / "stat").read_text().split(") ", 1)[1].split()
|
||||
if current[19] != started:
|
||||
raise RuntimeError("Процесс просмотрщика изменился.")
|
||||
print(
|
||||
"Закрываем зависший просмотрщик старого установщика; APT продолжает работу.", flush=True
|
||||
)
|
||||
signal.pidfd_send_signal(descriptor, signal.SIGTERM)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
deadline = time.monotonic() + 45
|
||||
while time.monotonic() < deadline:
|
||||
state = subprocess.run(query, capture_output=True, text=True, timeout=5).stdout.strip()
|
||||
if state == "0.8.17 install ok installed":
|
||||
return {"required": True, "pager_pid": pid, "old_package_configured": True}
|
||||
time.sleep(1)
|
||||
raise RuntimeError("Старая транзакция ещё не завершилась; она не прерывалась.")
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() or sys.argv[1:]:
|
||||
raise ValueError("Use the release's local Ubuntu installer")
|
||||
release = platform.freedesktop_os_release()
|
||||
if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != (
|
||||
"ubuntu",
|
||||
"24.04",
|
||||
"x86_64",
|
||||
):
|
||||
raise ValueError("This package requires Ubuntu 24.04 amd64")
|
||||
os.umask(0o077)
|
||||
source = Path(__file__).resolve().parent
|
||||
raw = (source / "release.json").read_bytes()
|
||||
manifest = json.loads(raw)
|
||||
version = manifest["version"]
|
||||
if (
|
||||
manifest.get("schema") != "missioncore.node.owner-release/v1"
|
||||
or manifest.get("qualification_profile") != "node-package-and-local-ui"
|
||||
or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[1-9][0-9]*)?", version)
|
||||
):
|
||||
raise ValueError("Unknown Node release")
|
||||
package = "mission-core-node_" + version + "_amd64.deb"
|
||||
if set(manifest["files"]) != {package, "install", "install_release.py"}:
|
||||
raise ValueError("Unexpected installer contents")
|
||||
contents = {}
|
||||
for name, expected in manifest["files"].items():
|
||||
data = (source / name).read_bytes()
|
||||
if len(data) != expected["bytes"] or hashlib.sha256(data).hexdigest() != expected["sha256"]:
|
||||
raise ValueError("Installer payload changed")
|
||||
contents[name] = data
|
||||
private(ROOT)
|
||||
folder = ROOT / uuid.uuid4().hex
|
||||
private(folder)
|
||||
(folder / package).write_bytes(contents[package])
|
||||
report = {
|
||||
"schema": "missioncore.node.install-run/v1",
|
||||
"session_id": folder.name,
|
||||
"release_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"package_sha256": manifest["files"][package]["sha256"],
|
||||
"started_at": datetime.now(UTC).isoformat(),
|
||||
"monotonic_started": time.monotonic(),
|
||||
"state": "running",
|
||||
"scope": "Node-package-and-UI-only; X4-prepare-remains-in-application",
|
||||
"steps": [],
|
||||
}
|
||||
env = {
|
||||
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"LANG": "C.UTF-8",
|
||||
"DEBIAN_FRONTEND": "noninteractive",
|
||||
"PAGER": "/bin/cat",
|
||||
"PSQL_PAGER": "/bin/cat",
|
||||
}
|
||||
|
||||
def publish():
|
||||
(folder / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
def run(name, command, timeout=60):
|
||||
print("Установка Node: " + name, flush=True)
|
||||
item = {"id": name, "state": "running", "started_at": datetime.now(UTC).isoformat()}
|
||||
report["steps"].append(item)
|
||||
publish()
|
||||
result = subprocess.run(command, env=env, capture_output=True, timeout=timeout)
|
||||
for suffix, data in (("stdout", result.stdout), ("stderr", result.stderr)):
|
||||
(folder / (name + "." + suffix)).write_bytes(data)
|
||||
item.update(
|
||||
state="complete" if result.returncode == 0 else "error", exit_code=result.returncode
|
||||
)
|
||||
publish()
|
||||
if result.returncode:
|
||||
raise RuntimeError("Не завершён этап установки: " + name)
|
||||
return result.stdout.decode().strip()
|
||||
|
||||
publish()
|
||||
try:
|
||||
report["legacy_pager_recovery"] = finish_legacy_monitor_pager()
|
||||
publish()
|
||||
report["services_before"] = run(
|
||||
"baseline",
|
||||
[
|
||||
"/usr/bin/systemctl",
|
||||
"show",
|
||||
*SERVICES,
|
||||
"-p",
|
||||
"Id",
|
||||
"-p",
|
||||
"ActiveState",
|
||||
"-p",
|
||||
"NRestarts",
|
||||
],
|
||||
)
|
||||
# APT refuses package removal and downgrade without explicit flags.
|
||||
# Its transaction is never killed by an observer timeout.
|
||||
run(
|
||||
"apt-plan",
|
||||
["/usr/bin/apt-get", "--simulate", "--no-remove", "install", str(folder / package)],
|
||||
)
|
||||
run(
|
||||
"package",
|
||||
[
|
||||
"/usr/bin/apt-get",
|
||||
"-o",
|
||||
"Dpkg::Use-Pty=0",
|
||||
"-o",
|
||||
"DPkg::Lock::Timeout=45",
|
||||
"install",
|
||||
"-y",
|
||||
"--no-remove",
|
||||
str(folder / package),
|
||||
],
|
||||
timeout=None,
|
||||
)
|
||||
installed = run(
|
||||
"installed",
|
||||
["/usr/bin/dpkg-query", "-W", "-f", "${Version}\t${Status}", "mission-core-node"],
|
||||
)
|
||||
if installed != version + "\tinstall ok installed":
|
||||
raise RuntimeError("Версия установленного Node не подтверждена.")
|
||||
run(
|
||||
"node-service",
|
||||
["/usr/bin/systemctl", "is-active", "--quiet", "mission-core-node.service"],
|
||||
)
|
||||
deadline = time.monotonic() + 30
|
||||
while True:
|
||||
client = http.client.HTTPConnection("127.0.0.1", 8780, timeout=2)
|
||||
try:
|
||||
client.request("GET", "/")
|
||||
response = client.getresponse()
|
||||
body = response.read(262145)
|
||||
if response.status == 200 and len(body) <= 262144 and b"<html" in body.lower():
|
||||
report["ui_ready"] = True
|
||||
break
|
||||
except (OSError, http.client.HTTPException):
|
||||
pass
|
||||
finally:
|
||||
client.close()
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("Пакет установлен, но локальный интерфейс Node не ответил.")
|
||||
time.sleep(1)
|
||||
report["services_after"] = run(
|
||||
"services-after",
|
||||
[
|
||||
"/usr/bin/systemctl",
|
||||
"show",
|
||||
*SERVICES,
|
||||
"-p",
|
||||
"Id",
|
||||
"-p",
|
||||
"ActiveState",
|
||||
"-p",
|
||||
"NRestarts",
|
||||
],
|
||||
)
|
||||
report["state"] = "complete"
|
||||
except Exception as error:
|
||||
report.update(state="error", error=str(error)[:500])
|
||||
finally:
|
||||
report.update(
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
duration_seconds=time.monotonic() - report["monotonic_started"],
|
||||
)
|
||||
publish()
|
||||
(folder / package).unlink(missing_ok=True)
|
||||
print("MISSION_CORE_NODE_RESULT " + json.dumps(report, ensure_ascii=False), flush=True)
|
||||
if report["state"] != "complete":
|
||||
raise RuntimeError(report["error"])
|
||||
print(
|
||||
"Mission Core Node обновлён. X4 можно подготовить из списка устройств в Node или Core.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema": "missioncore.node.build-toolchains/v1",
|
||||
"platform": "linux-amd64",
|
||||
"files": [
|
||||
{
|
||||
"name": "go1.26.8.linux-amd64.tar.gz",
|
||||
"url": "https://go.dev/dl/go1.26.8.linux-amd64.tar.gz",
|
||||
"sha256": "d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b",
|
||||
"directory": "go"
|
||||
},
|
||||
{
|
||||
"name": "node-v24.9.0-linux-x64.tar.xz",
|
||||
"url": "https://nodejs.org/dist/v24.9.0/node-v24.9.0-linux-x64.tar.xz",
|
||||
"sha256": "f52ec50e959d72d5c680d9731420b2661cd2a8070e94c7369b6ddfcd8b7278be",
|
||||
"directory": "node-v24.9.0-linux-x64"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Unprivileged self-verifying build, bounded by a transient user cgroup."""
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
ROOT = Path("/var/tmp/mission-core-node-builds")
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
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 != os.geteuid()
|
||||
or info.st_mode & 0o077
|
||||
):
|
||||
raise RuntimeError("Build staging is not private and owned")
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() == 0 or (platform.system(), platform.machine()) != ("Linux", "x86_64"):
|
||||
raise RuntimeError("Use the unprivileged Ubuntu build account")
|
||||
if sys.argv[1:] not in ([], ["--clean"]):
|
||||
raise ValueError("No arbitrary build commands or paths are accepted")
|
||||
os.umask(0o077)
|
||||
started, monotonic = datetime.now(UTC).isoformat(), time.monotonic()
|
||||
artifact = Path(sys.argv[0]).resolve()
|
||||
with zipfile.ZipFile(artifact) as archive:
|
||||
raw = archive.read("source.json")
|
||||
manifest = json.loads(raw)
|
||||
if manifest["schema"] != "missioncore.node.linux-build-source/v1":
|
||||
raise ValueError("Unsupported source artifact")
|
||||
if digest(archive.read("__main__.py")) != manifest["entrypoint_sha256"]:
|
||||
raise ValueError("Build entry point changed")
|
||||
identifier = digest(raw)[:24]
|
||||
private(ROOT)
|
||||
folder = ROOT / identifier
|
||||
private(folder)
|
||||
with (folder / "build.lock").open("a") as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if sys.argv[1:]:
|
||||
shutil.rmtree(folder)
|
||||
print(json.dumps({"cleaned": identifier}))
|
||||
return
|
||||
if (folder / "result.tar.gz").exists():
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result": str(folder / "result.tar.gz"),
|
||||
"sha256": digest((folder / "result.tar.gz").read_bytes()),
|
||||
"reused": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
if (folder / "source").exists():
|
||||
raise RuntimeError("Preserve prior failure evidence and use --clean before retry")
|
||||
if shutil.disk_usage(folder).free < 6 * 1024**3:
|
||||
raise RuntimeError("Build requires 6 GiB free temporary disk space")
|
||||
admitted = {"source.json", "__main__.py"} | {
|
||||
"source/" + name for name in manifest["files"]
|
||||
}
|
||||
if set(archive.namelist()) != admitted or len(archive.namelist()) != len(admitted):
|
||||
raise ValueError("Unexpected source contents")
|
||||
for name, expected in manifest["files"].items():
|
||||
path = PurePosixPath(name)
|
||||
if (
|
||||
path.is_absolute()
|
||||
or ".." in path.parts
|
||||
or path.as_posix() != name
|
||||
or "\\" in name
|
||||
):
|
||||
raise ValueError("Unsafe source path")
|
||||
entry = archive.getinfo("source/" + name)
|
||||
if entry.file_size != expected["bytes"] or entry.file_size > 100 * 1024**2:
|
||||
raise ValueError("Invalid source size")
|
||||
data = archive.read(entry)
|
||||
if digest(data) != expected["sha256"] or expected["mode"] not in (0o644, 0o755):
|
||||
raise ValueError("Invalid source bytes or mode")
|
||||
target = folder / "source" / name
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
target.chmod(expected["mode"])
|
||||
(folder / "source.json").write_bytes(raw)
|
||||
report = {
|
||||
"schema": "missioncore.node.linux-build-run/v1",
|
||||
"id": identifier,
|
||||
"started_at": started,
|
||||
"monotonic_started": monotonic,
|
||||
"state": "running",
|
||||
"artifact_sha256": digest(artifact.read_bytes()),
|
||||
"scope": "Node-Core-build-and-synthetic-tests",
|
||||
"resource_limits": {
|
||||
"memory_bytes": 3 * 1024**3,
|
||||
"cpu_percent": 150,
|
||||
"tasks": 256,
|
||||
"seconds": 1200,
|
||||
},
|
||||
}
|
||||
(folder / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
job = (
|
||||
folder
|
||||
/ "source"
|
||||
/ manifest["repository"]
|
||||
/ "apps/node-agent/packaging/linux_build_job.py"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"/usr/bin/systemd-run",
|
||||
"--user",
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"--wait",
|
||||
"--pipe",
|
||||
"--unit=mission-core-node-build-" + identifier,
|
||||
"--property=MemoryMax=3G",
|
||||
"--property=CPUQuota=150%",
|
||||
"--property=TasksMax=256",
|
||||
"--property=RuntimeMaxSec=1200",
|
||||
"--property=TimeoutStopSec=10",
|
||||
"--property=KillMode=control-group",
|
||||
"--property=Nice=10",
|
||||
"/usr/bin/python3",
|
||||
"-I",
|
||||
str(job),
|
||||
str(folder),
|
||||
],
|
||||
capture_output=True,
|
||||
)
|
||||
(folder / "job.stdout").write_bytes(result.stdout)
|
||||
(folder / "job.stderr").write_bytes(result.stderr)
|
||||
if result.returncode:
|
||||
raise RuntimeError("Node/Core build failed; inspect the private attempt 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")
|
||||
# The job creates the artifact list; these fixed files only
|
||||
# are collected after its cgroup has exited successfully.
|
||||
import tarfile
|
||||
|
||||
with tarfile.open(folder / "result.tar.gz", "w:gz") as result_archive:
|
||||
for path in sorted((folder / "output").iterdir()):
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ValueError("Unexpected build result")
|
||||
result_archive.add(path, arcname=path.name, recursive=False)
|
||||
result_archive.add(folder / "report.json", arcname="build-report.json")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result": str(folder / "result.tar.gz"),
|
||||
"state": "complete",
|
||||
"sha256": digest((folder / "result.tar.gz").read_bytes()),
|
||||
}
|
||||
)
|
||||
)
|
||||
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()
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Fixed sequential Node/Core build inside the source artifact's user cgroup."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() == 0 or len(sys.argv) != 2:
|
||||
raise ValueError("Use the versioned unprivileged build entry point")
|
||||
folder = Path(sys.argv[1])
|
||||
if folder.parent != Path("/var/tmp/mission-core-node-builds") or not re.fullmatch(
|
||||
r"[0-9a-f]{24}", folder.name
|
||||
):
|
||||
raise ValueError("Unknown build staging")
|
||||
manifest = json.loads((folder / "source.json").read_text())
|
||||
if manifest.get("profile", "node-core") not in ("node-core", "node-only"):
|
||||
raise ValueError("Unknown build profile")
|
||||
source = folder / "source"
|
||||
repo, dg = source / manifest["repository"], source / "NODEDC_DESIGN_GUIDELINE"
|
||||
node = repo / "apps/node-agent"
|
||||
if Path(__file__).resolve() != node / "packaging/linux_build_job.py":
|
||||
raise ValueError("Build source path changed")
|
||||
cgroup = next(
|
||||
line.split(":", 2)[2]
|
||||
for line in Path("/proc/self/cgroup").read_text().splitlines()
|
||||
if line.startswith("0::")
|
||||
)
|
||||
control = Path("/sys/fs/cgroup") / cgroup.lstrip("/")
|
||||
memory = (control / "memory.max").read_text().strip()
|
||||
cpu, period = (control / "cpu.max").read_text().split()
|
||||
tasks = (control / "pids.max").read_text().strip()
|
||||
if (
|
||||
memory == "max"
|
||||
or int(memory) > 3 * 1024**3
|
||||
or cpu == "max"
|
||||
or int(cpu) / int(period) > 1.5
|
||||
or tasks == "max"
|
||||
or int(tasks) > 256
|
||||
):
|
||||
raise RuntimeError("Build resource limits are not enforced")
|
||||
tools = folder / "toolchains"
|
||||
tools.mkdir(mode=0o700)
|
||||
for entry in manifest["toolchains"]["files"]:
|
||||
path = node / "build/linux-toolchains" / entry["name"]
|
||||
if hashlib.sha256(path.read_bytes()).hexdigest() != entry["sha256"]:
|
||||
raise ValueError("Toolchain changed before extraction")
|
||||
with tarfile.open(path) as archive:
|
||||
members = archive.getmembers()
|
||||
if len(members) > 40000 or sum(item.size for item in members) > 1024**3:
|
||||
raise ValueError("Toolchain exceeds extraction budget")
|
||||
for item in members:
|
||||
name = PurePosixPath(item.name)
|
||||
if (
|
||||
name.is_absolute()
|
||||
or ".." in name.parts
|
||||
or not name.parts
|
||||
or name.parts[0] != entry["directory"]
|
||||
):
|
||||
raise ValueError("Unexpected toolchain contents")
|
||||
# Python's data filter also rejects links escaping the owned tree,
|
||||
# devices, FIFOs and privilege-bearing modes in upstream archives.
|
||||
archive.extractall(tools, members=members, filter="data")
|
||||
go = tools / "go/bin/go"
|
||||
nodejs = tools / "node-v24.9.0-linux-x64/bin/node"
|
||||
npm = tools / "node-v24.9.0-linux-x64/lib/node_modules/npm/bin/npm-cli.js"
|
||||
output = folder / "output"
|
||||
output.mkdir(mode=0o700)
|
||||
temporary = folder / "temporary"
|
||||
temporary.mkdir(mode=0o700)
|
||||
env = {
|
||||
"PATH": str(nodejs.parent) + ":" + str(go.parent) + ":/usr/bin:/bin",
|
||||
"LANG": "C.UTF-8",
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"TMPDIR": str(temporary),
|
||||
"GOTOOLCHAIN": "local",
|
||||
"GOMAXPROCS": "2",
|
||||
"GOMEMLIMIT": "768MiB",
|
||||
"GOCACHE": str(folder / "go-cache"),
|
||||
"GOPATH": str(folder / "go-path"),
|
||||
"GOMODCACHE": str(folder / "go-mod"),
|
||||
"npm_config_cache": str(folder / "npm-cache"),
|
||||
"npm_config_audit": "false",
|
||||
"npm_config_fund": "false",
|
||||
"npm_config_update_notifier": "false",
|
||||
"NODE_OPTIONS": "--max-old-space-size=1024",
|
||||
}
|
||||
report = {
|
||||
"schema": "missioncore.node.linux-build-jobs/v1",
|
||||
"started_at": datetime.now(UTC).isoformat(),
|
||||
"monotonic_started": time.monotonic(),
|
||||
"state": "running",
|
||||
"jobs": [],
|
||||
"limits_observed": {"memory.max": memory, "cpu.max": [cpu, period], "pids.max": tasks},
|
||||
}
|
||||
|
||||
def publish():
|
||||
(output / "qualification.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
def run(name, command, cwd=repo, overrides=None, timeout=300):
|
||||
started = time.monotonic()
|
||||
item = {"id": name, "started_at": datetime.now(UTC).isoformat(), "state": "running"}
|
||||
report["jobs"].append(item)
|
||||
publish()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env={**env, **(overrides or {})},
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
(output / (name + ".stdout")).write_bytes(error.stdout or b"")
|
||||
(output / (name + ".stderr")).write_bytes(error.stderr or b"")
|
||||
item.update(
|
||||
state="error", reason="timeout", duration_seconds=time.monotonic() - started
|
||||
)
|
||||
publish()
|
||||
raise
|
||||
(output / (name + ".stdout")).write_bytes(result.stdout)
|
||||
(output / (name + ".stderr")).write_bytes(result.stderr)
|
||||
item.update(
|
||||
state="complete" if result.returncode == 0 else "error",
|
||||
exit_code=result.returncode,
|
||||
duration_seconds=time.monotonic() - started,
|
||||
)
|
||||
publish()
|
||||
if result.returncode:
|
||||
raise RuntimeError(name + " failed")
|
||||
return result.stdout.decode().strip()
|
||||
|
||||
def app_dependencies(app, name):
|
||||
lock = json.loads((app / "package-lock.json").read_text())
|
||||
for value in lock["packages"].values():
|
||||
resolved = value.get("resolved", "")
|
||||
if "://" in resolved and not resolved.startswith("https://registry.npmjs.org/"):
|
||||
raise ValueError("An npm dependency is outside the locked public registry")
|
||||
command = [
|
||||
str(nodejs),
|
||||
str(npm),
|
||||
"ci",
|
||||
"--ignore-scripts",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--prefer-offline",
|
||||
"--maxsockets=4",
|
||||
"--fetch-timeout=20000",
|
||||
"--fetch-retries=1",
|
||||
"--fetch-retry-mintimeout=1000",
|
||||
"--fetch-retry-maxtimeout=2000",
|
||||
]
|
||||
for attempt in range(1, 4):
|
||||
step = name + "-dependencies-" + str(attempt)
|
||||
try:
|
||||
run(step, command, cwd=app, timeout=45)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
# npm can stall after a TLS download without surfacing its
|
||||
# fetch timeout. Its killed process cannot overlap this retry.
|
||||
if attempt == 3:
|
||||
raise
|
||||
time.sleep(1)
|
||||
except RuntimeError:
|
||||
error = (output / (step + ".stderr")).read_text()
|
||||
if attempt == 3 or not any(
|
||||
code in error for code in ("ETIMEDOUT", "ECONNRESET", "EAI_AGAIN")
|
||||
):
|
||||
raise
|
||||
# Retry only a failed network fetch, retaining this attempt's
|
||||
# integrity-checked cache and every failed command log.
|
||||
time.sleep(1)
|
||||
|
||||
try:
|
||||
if run("go-version", [str(go), "version"]).split()[2] != "go1.26.8":
|
||||
raise ValueError("Unexpected Go toolchain")
|
||||
if run("node-version", [str(nodejs), "--version"]) != "v24.9.0":
|
||||
raise ValueError("Unexpected Node.js toolchain")
|
||||
app_dependencies(dg, "design-guideline")
|
||||
run("design-guideline-build", [str(nodejs), str(npm), "run", "build:packages"], cwd=dg)
|
||||
run(
|
||||
"design-guideline-typecheck",
|
||||
[str(nodejs), str(npm), "run", "typecheck", "--workspaces", "--if-present"],
|
||||
cwd=dg,
|
||||
)
|
||||
run(
|
||||
"design-guideline-registry", [str(nodejs), str(npm), "run", "validate:registry"], cwd=dg
|
||||
)
|
||||
run(
|
||||
"design-guideline-loading-tests",
|
||||
[str(nodejs), str(npm), "run", "test:activity-indicator"],
|
||||
cwd=dg,
|
||||
)
|
||||
run(
|
||||
"design-guideline-catalog",
|
||||
[str(nodejs), str(npm), "run", "build", "--workspace", "@nodedc/ui-catalog"],
|
||||
cwd=dg,
|
||||
overrides={"NODE_OPTIONS": "--max-old-space-size=2048"},
|
||||
)
|
||||
ui = node / "ui"
|
||||
app_dependencies(ui, "node-ui")
|
||||
run(
|
||||
"node-ui-tests",
|
||||
[
|
||||
str(nodejs),
|
||||
"--test",
|
||||
"--test-concurrency=1",
|
||||
*[str(path) for path in sorted((ui / "test").glob("*.test.mjs"))],
|
||||
],
|
||||
cwd=ui,
|
||||
)
|
||||
run("node-ui-build", [str(nodejs), str(npm), "run", "build"], cwd=ui)
|
||||
assets = node / "web/dist"
|
||||
if assets.exists():
|
||||
shutil.rmtree(assets)
|
||||
shutil.copytree(ui / "dist", assets)
|
||||
# The real UI must exist before Go compiles web/assets.go's embed.
|
||||
# Tests use private synthetic USB roots, never the Mini's inventory.
|
||||
run(
|
||||
"node-go-format-diff",
|
||||
[
|
||||
str(go.parent / "gofmt"),
|
||||
"-d",
|
||||
*[str(path) for path in sorted((node / "internal/node").glob("pairing*.go"))],
|
||||
],
|
||||
cwd=node,
|
||||
)
|
||||
run(
|
||||
"node-go-tests",
|
||||
[str(go), "test", "-race", "-p", "1", "./...", "-count=1"],
|
||||
cwd=node,
|
||||
overrides={"CGO_ENABLED": "1"},
|
||||
timeout=480,
|
||||
)
|
||||
sys.path.insert(0, str(node / "packaging"))
|
||||
from build_deb import BINARY_VERSION, VERSION, build
|
||||
|
||||
binary = node / "build/node-agent-linux-amd64"
|
||||
run(
|
||||
"node-binary",
|
||||
[
|
||||
str(go),
|
||||
"build",
|
||||
"-p",
|
||||
"1",
|
||||
"-trimpath",
|
||||
"-ldflags=-s -w -X main.version=" + BINARY_VERSION,
|
||||
"-o",
|
||||
str(binary),
|
||||
"./cmd/node-agent",
|
||||
],
|
||||
cwd=node,
|
||||
overrides={"CGO_ENABLED": "0", "GOOS": "linux", "GOARCH": "amd64"},
|
||||
)
|
||||
provenance = {
|
||||
"schema": "missioncore.node.source-provenance/v1",
|
||||
"version": VERSION,
|
||||
"base_commit": manifest["base_commit"],
|
||||
"design_guideline_commit": manifest["design_guideline_commit"],
|
||||
"source_manifest_sha256": hashlib.sha256(
|
||||
(folder / "source.json").read_bytes()
|
||||
).hexdigest(),
|
||||
"toolchains": manifest["toolchains"],
|
||||
"files": manifest["files"],
|
||||
}
|
||||
(node / "build/provenance.json").write_text(json.dumps(provenance, indent=2) + "\n")
|
||||
package = output / ("mission-core-node_" + VERSION + "_amd64.deb")
|
||||
build(binary, package)
|
||||
if manifest.get("profile") == "node-only":
|
||||
report.update(
|
||||
state="complete",
|
||||
profile="node-only",
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
duration_seconds=time.monotonic() - report["monotonic_started"],
|
||||
memory_peak_bytes=int((control / "memory.peak").read_text()),
|
||||
)
|
||||
report["artifacts"] = {
|
||||
package.name: {
|
||||
"bytes": package.stat().st_size,
|
||||
"sha256": hashlib.sha256(package.read_bytes()).hexdigest(),
|
||||
}
|
||||
}
|
||||
publish()
|
||||
return
|
||||
core = repo / "apps/control-station"
|
||||
app_dependencies(core, "core")
|
||||
run(
|
||||
"core-architecture",
|
||||
[str(nodejs), "--test", "test/applicationArchitecture.test.mjs"],
|
||||
cwd=core,
|
||||
)
|
||||
run("core-typecheck", [str(nodejs), str(npm), "run", "typecheck"], cwd=core)
|
||||
# This existing K1 integration case generates RRD via a developer
|
||||
# Python/Rerun environment. That environment is not a Node/X4 build
|
||||
# dependency. Keep every other test; report this acceptance gap.
|
||||
report["unqualified_tests"] = [
|
||||
{
|
||||
"name": "native RRD crosses many RTC fragments "
|
||||
"and reaches decoder once, byte-for-byte",
|
||||
"reason": "Python/Rerun fixture generator is outside the Node/X4 build inputs",
|
||||
}
|
||||
]
|
||||
run(
|
||||
"core-tests",
|
||||
[
|
||||
str(nodejs),
|
||||
"--test",
|
||||
"--test-concurrency=1",
|
||||
"--test-skip-pattern=^native RRD crosses many RTC fragments "
|
||||
"and reaches decoder once, byte-for-byte$",
|
||||
*[str(path) for path in sorted((core / "test").glob("*.test.mjs"))],
|
||||
],
|
||||
cwd=core,
|
||||
timeout=480,
|
||||
)
|
||||
run(
|
||||
"core-build",
|
||||
[str(nodejs), str(npm), "run", "build"],
|
||||
cwd=core,
|
||||
overrides={"NODE_OPTIONS": "--max-old-space-size=2048"},
|
||||
)
|
||||
with tarfile.open(output / "core-dist.tar.gz", "w:gz") as archive:
|
||||
for path in sorted((core / "dist").rglob("*")):
|
||||
if path.is_file():
|
||||
archive.add(path, arcname=str(path.relative_to(core / "dist")), recursive=False)
|
||||
report.update(
|
||||
state="complete",
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
duration_seconds=time.monotonic() - report["monotonic_started"],
|
||||
memory_peak_bytes=int((control / "memory.peak").read_text()),
|
||||
)
|
||||
report["artifacts"] = {
|
||||
path.name: {
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
}
|
||||
for path in (package, output / "core-dist.tar.gz")
|
||||
}
|
||||
publish()
|
||||
except Exception:
|
||||
report.update(
|
||||
state="error",
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
duration_seconds=time.monotonic() - report["monotonic_started"],
|
||||
)
|
||||
publish()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Mission Core fixed bundled Insta360 X4 profile
|
||||
After=systemd-udevd.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360_profile.py
|
||||
# Never kill dpkg in the middle of package configuration. The requesting Node
|
||||
# operation has its own deadline; an expired observer cannot cancel this job.
|
||||
TimeoutStartSec=infinity
|
||||
UMask=0022
|
||||
@@ -14,6 +14,10 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true)
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
|
||||
@@ -13,6 +13,10 @@ if [ -d /run/systemd/system ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
mc_node_x4_job=$(systemctl show --property=ActiveState --value mission-core-node-insta360-x4-profile.service 2>/dev/null || true)
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Repack qualified N08: only disable psql paging in the monitoring installer."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(NODE.parents[1] / "scripts/packaging"))
|
||||
from debian import package # noqa: E402
|
||||
|
||||
BASE = "f929c440e4965f728aa6cd00eb122e7cad09e5ade82da763cc36661f50ec66b5"
|
||||
TARGET = "usr/lib/mission-core-node/setup-monitor"
|
||||
|
||||
|
||||
def main():
|
||||
original = NODE / "build/qualified-n08/mission-core-node_0.8.17_amd64.deb"
|
||||
raw = original.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != BASE or raw[:8] != b"!<arch>\n":
|
||||
raise ValueError("The qualified Node package changed")
|
||||
entries, cursor = {}, 8
|
||||
while cursor < len(raw):
|
||||
header = raw[cursor : cursor + 60]
|
||||
if header[58:] != b"`\n":
|
||||
raise ValueError("Invalid Debian archive")
|
||||
name, size = header[:16].decode().strip().rstrip("/"), int(header[48:58])
|
||||
if name in entries:
|
||||
raise ValueError("Duplicate archive member")
|
||||
entries[name] = raw[cursor + 60 : cursor + 60 + size]
|
||||
cursor += 60 + size + size % 2
|
||||
if set(entries) != {"debian-binary", "control.tar.gz", "data.tar.gz"}:
|
||||
raise ValueError("Unexpected Debian contents")
|
||||
|
||||
def files(name):
|
||||
result = []
|
||||
with tarfile.open(fileobj=io.BytesIO(entries[name]), mode="r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
if member.isdir():
|
||||
continue
|
||||
if not member.isfile() or member.uid or member.gid:
|
||||
raise ValueError("Unexpected package member")
|
||||
result.append((member.name, archive.extractfile(member).read(), member.mode))
|
||||
return result
|
||||
|
||||
controls, data = files("control.tar.gz"), files("data.tar.gz")
|
||||
control_count = sum(name == "control" for name, _, _ in controls)
|
||||
if control_count != 1:
|
||||
raise ValueError("Unexpected control metadata")
|
||||
controls = [
|
||||
(
|
||||
name,
|
||||
value.replace(b"Version: 0.8.17\n", b"Version: 0.8.17-1\n")
|
||||
if name == "control"
|
||||
else value,
|
||||
mode,
|
||||
)
|
||||
for name, value, mode in controls
|
||||
]
|
||||
fixed = (NODE / "packaging/setup-monitor").read_bytes()
|
||||
originals = [value for name, value, _ in data if name == TARGET]
|
||||
if len(originals) != 1 or fixed.count(b" -P pager=off") != 3:
|
||||
raise ValueError("Unexpected monitor patch")
|
||||
if fixed.replace(b" -P pager=off", b"") != originals[0]:
|
||||
raise ValueError("This recovery admits only the three pager flags")
|
||||
data = [(name, fixed if name == TARGET else value, mode) for name, value, mode in data]
|
||||
proof = {
|
||||
"schema": "missioncore.node.packaging-revision/v1",
|
||||
"version": "0.8.17-1",
|
||||
"qualified_base_sha256": BASE,
|
||||
"changed_files": [TARGET],
|
||||
"binary_ui_and_model_packages_unchanged": True,
|
||||
"setup_monitor_sha256": hashlib.sha256(fixed).hexdigest(),
|
||||
}
|
||||
data.append(
|
||||
(
|
||||
"usr/share/doc/mission-core-node/packaging-revision.json",
|
||||
(json.dumps(proof, indent=2) + "\n").encode(),
|
||||
0o644,
|
||||
)
|
||||
)
|
||||
folder = NODE / "build/qualified-n08-r2"
|
||||
folder.mkdir(mode=0o700)
|
||||
path = folder / "mission-core-node_0.8.17-1_amd64.deb"
|
||||
path.write_bytes(package(controls, data))
|
||||
evidence = json.loads((NODE / "build/qualified-n08/qualification.json").read_text())
|
||||
evidence["packaging_revision"] = proof
|
||||
evidence["artifacts"] = {
|
||||
path.name: {
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
}
|
||||
}
|
||||
(folder / "qualification.json").write_text(json.dumps(evidence, indent=2) + "\n")
|
||||
print(json.dumps({"folder": str(folder), "artifacts": evidence["artifacts"]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""P07: deliver the qualified X4 streaming fix without recompiling Node or its UI."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
REPO = NODE.parents[1]
|
||||
sys.path.insert(0, str(REPO / "scripts/packaging"))
|
||||
from debian import package # noqa: E402
|
||||
|
||||
BASE = "00f4baefd0c6f4e8f56bbf881329dfd36935e2334f35e2d17eb685a969d4c181"
|
||||
|
||||
|
||||
def main():
|
||||
original = NODE / "build/qualified-n08-r4/mission-core-node_0.8.17-3_amd64.deb"
|
||||
raw = original.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != BASE or raw[:8] != b"!<arch>\n":
|
||||
raise ValueError("Qualified Node package changed")
|
||||
entries, cursor = {}, 8
|
||||
while cursor < len(raw):
|
||||
header = raw[cursor : cursor + 60]
|
||||
name, size = header[:16].decode().strip().rstrip("/"), int(header[48:58])
|
||||
if header[58:] != b"`\n" or name in entries:
|
||||
raise ValueError("Invalid archive")
|
||||
entries[name] = raw[cursor + 60 : cursor + 60 + size]
|
||||
cursor += 60 + size + size % 2
|
||||
if set(entries) != {"debian-binary", "control.tar.gz", "data.tar.gz"}:
|
||||
raise ValueError("Unexpected archive contents")
|
||||
|
||||
def files(name):
|
||||
result = []
|
||||
with tarfile.open(fileobj=io.BytesIO(entries[name]), mode="r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
if member.isdir():
|
||||
continue
|
||||
if not member.isfile() or member.uid or member.gid:
|
||||
raise ValueError("Unexpected package member")
|
||||
result.append((member.name, archive.extractfile(member).read(), member.mode))
|
||||
return result
|
||||
|
||||
controls, data = files("control.tar.gz"), files("data.tar.gz")
|
||||
old = {name: value for name, value, _ in data}
|
||||
profile_path = "usr/lib/mission-core-node/insta360_profile.py"
|
||||
profile = (NODE / "packaging/insta360_profile.py").read_bytes()
|
||||
if profile != old[profile_path]:
|
||||
raise ValueError("The qualified bootstrap must remain unchanged")
|
||||
qualified = REPO / "plugins/insta360-x4/build/package-b10"
|
||||
result = json.loads((qualified / "package-result.json").read_text())
|
||||
checks = json.loads((qualified / "build-report.json").read_text())
|
||||
if checks["state"] != "complete" or result["version"] != "0.1.3-2":
|
||||
raise ValueError("The fixed X4 package is not qualified")
|
||||
model_name = "mission-core-insta360-x4_0.1.3-2_amd64.deb"
|
||||
model = (qualified / model_name).read_bytes()
|
||||
if len(model) != result["bytes"] or hashlib.sha256(model).hexdigest() != result["sha256"]:
|
||||
raise ValueError("Qualified model package changed")
|
||||
manifest = {
|
||||
"schema": "missioncore.node.bundled-model/v1",
|
||||
"model_id": "insta360.x4",
|
||||
**{key: result[key] for key in ("version", "revision", "bytes", "sha256")},
|
||||
}
|
||||
model_root = "usr/share/mission-core-node/profiles/insta360-x4/"
|
||||
changes = {
|
||||
profile_path: profile,
|
||||
model_root + "profile.json": (json.dumps(manifest, indent=2) + "\n").encode(),
|
||||
}
|
||||
proof_path = "usr/share/doc/mission-core-node/packaging-revision.json"
|
||||
proof = {
|
||||
"schema": "missioncore.node.packaging-revision/v1",
|
||||
"version": "0.8.17-4",
|
||||
"qualified_base_sha256": BASE,
|
||||
"changed_files": list(changes) + [model_root + model_name],
|
||||
"binary_and_ui_unchanged": True,
|
||||
"model": manifest,
|
||||
"model_checks": checks,
|
||||
}
|
||||
changes[proof_path] = (json.dumps(proof, indent=2) + "\n").encode()
|
||||
if not set(changes) <= old.keys():
|
||||
raise ValueError("Unexpected replacement paths")
|
||||
old_model = model_root + "mission-core-insta360-x4_0.1.3-1_amd64.deb"
|
||||
if (
|
||||
hashlib.sha256(old[old_model]).hexdigest()
|
||||
!= "c11977a4d4c9779b96ee38ff3a474bdd820d9e3e5ecc642e7711762def14b6da"
|
||||
):
|
||||
raise ValueError("Unexpected prior model bundle")
|
||||
data = [
|
||||
(name, changes.get(name, value), mode) for name, value, mode in data if name != old_model
|
||||
]
|
||||
data.append((model_root + model_name, model, 0o644))
|
||||
for index, (name, value, mode) in enumerate(controls):
|
||||
if name == "control":
|
||||
if value.count(b"Version: 0.8.17-3\n") != 1:
|
||||
raise ValueError("Unexpected Node version")
|
||||
controls[index] = (
|
||||
name,
|
||||
value.replace(b"Version: 0.8.17-3\n", b"Version: 0.8.17-4\n"),
|
||||
mode,
|
||||
)
|
||||
folder = NODE / "build/qualified-n08-r5"
|
||||
folder.mkdir(mode=0o700)
|
||||
target = folder / "mission-core-node_0.8.17-4_amd64.deb"
|
||||
target.write_bytes(package(controls, data))
|
||||
evidence = json.loads((NODE / "build/qualified-n08-r4/qualification.json").read_text())
|
||||
evidence["packaging_revision"] = proof
|
||||
evidence["artifacts"] = {
|
||||
target.name: {
|
||||
"bytes": target.stat().st_size,
|
||||
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
|
||||
}
|
||||
}
|
||||
(folder / "qualification.json").write_text(json.dumps(evidence, indent=2) + "\n")
|
||||
(NODE / "packaging/insta360-profile.json").write_bytes(changes[model_root + "profile.json"])
|
||||
model_directory = NODE / "build/model-packages"
|
||||
model_directory.mkdir(mode=0o700, exist_ok=True)
|
||||
(model_directory / model_name).write_bytes(model)
|
||||
print(
|
||||
json.dumps({"folder": str(folder), "artifacts": evidence["artifacts"], "model": manifest})
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
"""P09: deliver B11 through qualified Node 0.8.18 without changing binary/UI."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
REPO = NODE.parents[1]
|
||||
sys.path.insert(0, str(REPO / "scripts/packaging"))
|
||||
from debian import package # noqa: E402
|
||||
|
||||
BASE = "db340f7d117a7cdf4707bbf43f58c0e8307c8ac3813b7bc7dd5ca8a4a47f73b2"
|
||||
|
||||
|
||||
def main():
|
||||
original = NODE / "build/qualified-n09/mission-core-node_0.8.18_amd64.deb"
|
||||
raw = original.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != BASE or raw[:8] != b"!<arch>\n":
|
||||
raise ValueError("Qualified Node package changed")
|
||||
entries, cursor = {}, 8
|
||||
while cursor < len(raw):
|
||||
header = raw[cursor : cursor + 60]
|
||||
name, size = header[:16].decode().strip().rstrip("/"), int(header[48:58])
|
||||
if header[58:] != b"`\n" or name in entries:
|
||||
raise ValueError("Invalid archive")
|
||||
entries[name] = raw[cursor + 60 : cursor + 60 + size]
|
||||
cursor += 60 + size + size % 2
|
||||
if set(entries) != {"debian-binary", "control.tar.gz", "data.tar.gz"}:
|
||||
raise ValueError("Unexpected archive contents")
|
||||
|
||||
def files(name):
|
||||
result = []
|
||||
with tarfile.open(fileobj=io.BytesIO(entries[name]), mode="r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
if member.isdir():
|
||||
continue
|
||||
if not member.isfile() or member.uid or member.gid:
|
||||
raise ValueError("Unexpected package member")
|
||||
result.append((member.name, archive.extractfile(member).read(), member.mode))
|
||||
return result
|
||||
|
||||
controls, data = files("control.tar.gz"), files("data.tar.gz")
|
||||
old = {name: value for name, value, _ in data}
|
||||
profile_path = "usr/lib/mission-core-node/insta360_profile.py"
|
||||
profile = (NODE / "packaging/insta360_profile.py").read_bytes()
|
||||
if profile != old[profile_path]:
|
||||
raise ValueError("The qualified bootstrap must remain unchanged")
|
||||
qualified = REPO / "plugins/insta360-x4/build/package-b11"
|
||||
result = json.loads((qualified / "package-result.json").read_text())
|
||||
checks = json.loads((qualified / "build-report.json").read_text())
|
||||
if checks["state"] != "complete" or result["version"] != "0.1.3-3":
|
||||
raise ValueError("The fixed X4 package is not qualified")
|
||||
model_name = "mission-core-insta360-x4_0.1.3-3_amd64.deb"
|
||||
model = (qualified / model_name).read_bytes()
|
||||
if len(model) != result["bytes"] or hashlib.sha256(model).hexdigest() != result["sha256"]:
|
||||
raise ValueError("Qualified model package changed")
|
||||
manifest = {
|
||||
"schema": "missioncore.node.bundled-model/v1",
|
||||
"model_id": "insta360.x4",
|
||||
**{key: result[key] for key in ("version", "revision", "bytes", "sha256")},
|
||||
}
|
||||
model_root = "usr/share/mission-core-node/profiles/insta360-x4/"
|
||||
changes = {
|
||||
profile_path: profile,
|
||||
model_root + "profile.json": (json.dumps(manifest, indent=2) + "\n").encode(),
|
||||
}
|
||||
proof_path = "usr/share/doc/mission-core-node/packaging-revision.json"
|
||||
proof = {
|
||||
"schema": "missioncore.node.packaging-revision/v1",
|
||||
"version": "0.8.18-1",
|
||||
"qualified_base_sha256": BASE,
|
||||
"changed_files": list(changes) + [model_root + model_name],
|
||||
"binary_and_ui_unchanged": True,
|
||||
"model": manifest,
|
||||
"model_checks": checks,
|
||||
}
|
||||
changes[proof_path] = (json.dumps(proof, indent=2) + "\n").encode()
|
||||
if not set(changes) - {proof_path} <= old.keys():
|
||||
raise ValueError("Unexpected replacement paths")
|
||||
old_model = model_root + "mission-core-insta360-x4_0.1.3-2_amd64.deb"
|
||||
if (
|
||||
hashlib.sha256(old[old_model]).hexdigest()
|
||||
!= "2931a5206478fcac67f74bd1fb579b98c0fcc43eda4c05e5ac4a721947619130"
|
||||
):
|
||||
raise ValueError("Unexpected prior model bundle")
|
||||
data = [
|
||||
(name, changes.get(name, value), mode) for name, value, mode in data if name != old_model
|
||||
]
|
||||
data.append((model_root + model_name, model, 0o644))
|
||||
if proof_path not in old:
|
||||
data.append((proof_path, changes[proof_path], 0o644))
|
||||
for name, content, _mode in data:
|
||||
if name not in changes and name != model_root + model_name and content != old[name]:
|
||||
raise ValueError("Unrelated qualified payload changed")
|
||||
for index, (name, value, mode) in enumerate(controls):
|
||||
if name == "control":
|
||||
if value.count(b"Version: 0.8.18\n") != 1:
|
||||
raise ValueError("Unexpected Node version")
|
||||
controls[index] = (
|
||||
name,
|
||||
value.replace(b"Version: 0.8.18\n", b"Version: 0.8.18-1\n"),
|
||||
mode,
|
||||
)
|
||||
folder = NODE / "build/qualified-n09-r1"
|
||||
folder.mkdir(mode=0o700)
|
||||
target = folder / "mission-core-node_0.8.18-1_amd64.deb"
|
||||
target.write_bytes(package(controls, data))
|
||||
evidence = json.loads((NODE / "build/qualified-n09/qualification.json").read_text())
|
||||
evidence["packaging_revision"] = proof
|
||||
evidence["artifacts"] = {
|
||||
target.name: {
|
||||
"bytes": target.stat().st_size,
|
||||
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
|
||||
}
|
||||
}
|
||||
(folder / "qualification.json").write_text(json.dumps(evidence, indent=2) + "\n")
|
||||
(NODE / "packaging/insta360-profile.json").write_bytes(changes[model_root + "profile.json"])
|
||||
model_directory = NODE / "build/model-packages"
|
||||
model_directory.mkdir(mode=0o700, exist_ok=True)
|
||||
(model_directory / model_name).write_bytes(model)
|
||||
print(
|
||||
json.dumps({"folder": str(folder), "artifacts": evidence["artifacts"], "model": manifest})
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""P05: replace only X4 bootstrap in qualified Node 0.8.17-1; retain compiled bytes."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(NODE.parents[1] / "scripts/packaging"))
|
||||
from debian import package # noqa: E402
|
||||
|
||||
BASE = "7033ad608765231b20773dee45edac3d8f63e65525410de2346c79b3f8756a4b"
|
||||
TARGET = "usr/lib/mission-core-node/insta360_profile.py"
|
||||
PROOF = "usr/share/doc/mission-core-node/packaging-revision.json"
|
||||
|
||||
|
||||
def main():
|
||||
original = NODE / "build/qualified-n08-r2/mission-core-node_0.8.17-1_amd64.deb"
|
||||
raw = original.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != BASE or raw[:8] != b"!<arch>\n":
|
||||
raise ValueError("Qualified package changed")
|
||||
entries, cursor = {}, 8
|
||||
while cursor < len(raw):
|
||||
header = raw[cursor : cursor + 60]
|
||||
name, size = header[:16].decode().strip().rstrip("/"), int(header[48:58])
|
||||
if header[58:] != b"`\n" or name in entries:
|
||||
raise ValueError("Invalid archive")
|
||||
entries[name] = raw[cursor + 60 : cursor + 60 + size]
|
||||
cursor += 60 + size + size % 2
|
||||
if set(entries) != {"debian-binary", "control.tar.gz", "data.tar.gz"}:
|
||||
raise ValueError("Unexpected package contents")
|
||||
|
||||
def files(name):
|
||||
result = []
|
||||
with tarfile.open(fileobj=io.BytesIO(entries[name]), mode="r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
if member.isdir():
|
||||
continue
|
||||
if not member.isfile() or member.uid or member.gid:
|
||||
raise ValueError("Unexpected package member")
|
||||
result.append((member.name, archive.extractfile(member).read(), member.mode))
|
||||
return result
|
||||
|
||||
controls, data = files("control.tar.gz"), files("data.tar.gz")
|
||||
original_control = [value for name, value, _ in controls if name == "control"]
|
||||
if len(original_control) != 1 or original_control[0].count(b"Version: 0.8.17-1\n") != 1:
|
||||
raise ValueError("Unexpected control version")
|
||||
fixed = (NODE / "packaging/insta360_profile.py").read_bytes()
|
||||
checks = json.loads((NODE / "build/x4-profile-p05-check.json").read_text())
|
||||
if (
|
||||
checks["state"] != "complete"
|
||||
or checks["files"]["apps/node-agent/packaging/insta360_profile.py"]
|
||||
!= hashlib.sha256(fixed).hexdigest()
|
||||
):
|
||||
raise ValueError("Current profile has not passed Ubuntu checks")
|
||||
if sum(name == TARGET for name, _, _ in data) != 1:
|
||||
raise ValueError("Missing profile")
|
||||
proof = {
|
||||
"schema": "missioncore.node.packaging-revision/v1",
|
||||
"version": "0.8.17-2",
|
||||
"qualified_base_sha256": BASE,
|
||||
"changed_files": [TARGET],
|
||||
"binary_ui_and_model_packages_unchanged": True,
|
||||
"profile_sha256": hashlib.sha256(fixed).hexdigest(),
|
||||
"profile_check": checks,
|
||||
}
|
||||
controls = [
|
||||
(
|
||||
name,
|
||||
value.replace(b"Version: 0.8.17-1\n", b"Version: 0.8.17-2\n")
|
||||
if name == "control"
|
||||
else value,
|
||||
mode,
|
||||
)
|
||||
for name, value, mode in controls
|
||||
]
|
||||
data = [
|
||||
(
|
||||
name,
|
||||
fixed
|
||||
if name == TARGET
|
||||
else (json.dumps(proof, indent=2) + "\n").encode()
|
||||
if name == PROOF
|
||||
else value,
|
||||
mode,
|
||||
)
|
||||
for name, value, mode in data
|
||||
]
|
||||
folder = NODE / "build/qualified-n08-r3"
|
||||
folder.mkdir(mode=0o700)
|
||||
path = folder / "mission-core-node_0.8.17-2_amd64.deb"
|
||||
path.write_bytes(package(controls, data))
|
||||
evidence = json.loads((NODE / "build/qualified-n08-r2/qualification.json").read_text())
|
||||
evidence["packaging_revision"] = proof
|
||||
evidence["artifacts"] = {
|
||||
path.name: {
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
}
|
||||
}
|
||||
(folder / "qualification.json").write_text(json.dumps(evidence, indent=2) + "\n")
|
||||
print(json.dumps({"folder": str(folder), "artifacts": evidence["artifacts"]}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,179 @@
|
||||
"""One fixed recovery of N08's qualified sources after the Core V8 heap limit.
|
||||
|
||||
No dependency installation, source edits, Go recompilation or hardware access.
|
||||
The original failed report and all stderr remain part of the result archive.
|
||||
"""
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import resource
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
IDENTIFIER = "511010461b4f2cd4c0546af9"
|
||||
SOURCE_HASH = "511010461b4f2cd4c0546af946adcb696a03d3134dc0f68c4b5c62b8bc57d766"
|
||||
REPORT_HASH = "e5481d0cb9e2799ffe2ca4ec60390d1557e363b93e192e52df92e58fae3d21e6"
|
||||
PACKAGE_HASH = "f929c440e4965f728aa6cd00eb122e7cad09e5ade82da763cc36661f50ec66b5"
|
||||
FOLDER = Path("/var/tmp/mission-core-node-builds") / IDENTIFIER
|
||||
PACKAGE = "mission-core-node_0.8.17_amd64.deb"
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() == 0 or (platform.system(), platform.machine()) != ("Linux", "x86_64"):
|
||||
raise ValueError("Use the unprivileged Ubuntu build account")
|
||||
if sys.argv[1:] not in ([], ["--job"]):
|
||||
raise ValueError("Only the fixed N08 Core recovery is admitted")
|
||||
os.umask(0o077)
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
for path in (FOLDER.parent, FOLDER):
|
||||
info = path.lstat()
|
||||
if path.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise ValueError("Build attempt is not private and owned")
|
||||
output = FOLDER / "output"
|
||||
if digest(FOLDER / "source.json") != SOURCE_HASH:
|
||||
raise ValueError("Source manifest changed")
|
||||
manifest = json.loads((FOLDER / "source.json").read_text())
|
||||
if digest(output / PACKAGE) != PACKAGE_HASH:
|
||||
raise ValueError("Qualified Node package changed")
|
||||
if not sys.argv[1:]:
|
||||
with (FOLDER / "build.lock").open("a") as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
subprocess.run(
|
||||
[
|
||||
"/usr/bin/systemd-run",
|
||||
"--user",
|
||||
"--collect",
|
||||
"--wait",
|
||||
"--pipe",
|
||||
"--unit=mission-core-node-core-resume-" + IDENTIFIER,
|
||||
"--property=MemoryMax=3G",
|
||||
"--property=CPUQuota=150%",
|
||||
"--property=TasksMax=256",
|
||||
"--property=RuntimeMaxSec=300",
|
||||
"--property=Nice=10",
|
||||
"/usr/bin/python3",
|
||||
str(Path(__file__).resolve()),
|
||||
"--job",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
with tarfile.open(FOLDER / "result.tar.gz", "w:gz") as archive:
|
||||
for path in sorted(output.iterdir()):
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ValueError("Unexpected build output")
|
||||
archive.add(path, arcname=path.name, recursive=False)
|
||||
archive.add(FOLDER / "report.json", arcname="build-report.json", recursive=False)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result": str(FOLDER / "result.tar.gz"),
|
||||
"sha256": digest(FOLDER / "result.tar.gz"),
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
group = next(
|
||||
line.split(":", 2)[2]
|
||||
for line in Path("/proc/self/cgroup").read_text().splitlines()
|
||||
if line.startswith("0::")
|
||||
)
|
||||
control = Path("/sys/fs/cgroup") / group.lstrip("/")
|
||||
cpu, period = map(int, (control / "cpu.max").read_text().split())
|
||||
if (
|
||||
int((control / "memory.max").read_text()) != 3 * 1024**3
|
||||
or cpu / period > 1.5
|
||||
or int((control / "pids.max").read_text()) > 256
|
||||
):
|
||||
raise ValueError("Recovery cgroup limits are not enforced")
|
||||
for name, expected in manifest["files"].items():
|
||||
path = FOLDER / "source" / name
|
||||
if path.is_symlink() or digest(path) != expected["sha256"]:
|
||||
raise ValueError("A previously qualified input changed: " + name)
|
||||
if digest(output / "qualification.json") != REPORT_HASH:
|
||||
raise ValueError("Qualification checkpoint changed")
|
||||
report = json.loads((output / "qualification.json").read_text())
|
||||
if (
|
||||
report["state"] != "error"
|
||||
or report["jobs"][-1]["id"] != "core-build"
|
||||
or report["jobs"][-1]["exit_code"] != 134
|
||||
):
|
||||
raise ValueError("This recovery only admits N08's V8 heap failure")
|
||||
with (output / "qualification-before-core-resume.json").open("xb") as stream:
|
||||
stream.write((output / "qualification.json").read_bytes())
|
||||
repo = FOLDER / "source" / manifest["repository"]
|
||||
tool = FOLDER / "toolchains/node-v24.9.0-linux-x64"
|
||||
env = {
|
||||
"PATH": str(tool / "bin") + ":/usr/bin:/bin",
|
||||
"LANG": "C.UTF-8",
|
||||
"TMPDIR": str(FOLDER / "temporary"),
|
||||
"NODE_OPTIONS": "--max-old-space-size=2048",
|
||||
"npm_config_cache": str(FOLDER / "npm-cache"),
|
||||
"npm_config_audit": "false",
|
||||
"npm_config_fund": "false",
|
||||
"npm_config_update_notifier": "false",
|
||||
}
|
||||
started = time.monotonic()
|
||||
job = {
|
||||
"id": "core-build-memory-recovery",
|
||||
"started_at": datetime.now(UTC).isoformat(),
|
||||
"monotonic_started": started,
|
||||
"memory_max_bytes": 3 * 1024**3,
|
||||
"v8_heap_mib": 2048,
|
||||
"source_manifest_sha256": SOURCE_HASH,
|
||||
}
|
||||
with (
|
||||
(output / "core-build-recovery.stdout").open("wb") as out,
|
||||
(output / "core-build-recovery.stderr").open("wb") as err,
|
||||
):
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(tool / "bin/node"),
|
||||
str(tool / "lib/node_modules/npm/bin/npm-cli.js"),
|
||||
"run",
|
||||
"build",
|
||||
],
|
||||
cwd=repo / "apps/control-station",
|
||||
env=env,
|
||||
stdout=out,
|
||||
stderr=err,
|
||||
timeout=280,
|
||||
)
|
||||
job.update(
|
||||
exit_code=result.returncode,
|
||||
state="complete" if result.returncode == 0 else "error",
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
memory_peak_bytes=int((control / "memory.peak").read_text()),
|
||||
)
|
||||
(output / "core-resume-report.json").write_text(json.dumps(job, indent=2) + "\n")
|
||||
if result.returncode:
|
||||
raise RuntimeError("Core build recovery failed; preserve its evidence")
|
||||
dist = repo / "apps/control-station/dist"
|
||||
with tarfile.open(output / "core-dist.tar.gz", "w:gz") as archive:
|
||||
for path in sorted(dist.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.add(path, arcname=str(path.relative_to(dist)), recursive=False)
|
||||
report["jobs"].append(job)
|
||||
report.update(
|
||||
state="complete", finished_at=datetime.now(UTC).isoformat(), recovered_from=REPORT_HASH
|
||||
)
|
||||
report["artifacts"] = {
|
||||
path.name: {"bytes": path.stat().st_size, "sha256": digest(path)}
|
||||
for path in (output / PACKAGE, output / "core-dist.tar.gz")
|
||||
}
|
||||
(output / "qualification.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Fixed N09 test-contract recovery; no installed files or camera access."""
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
IDENTIFIER = "d7aa79960717e4d62ab69633"
|
||||
SOURCE_HASH = "d7aa79960717e4d62ab69633ec9ddc1ddabfa33998043635bdef2dbac807a3ca"
|
||||
REPORT_HASH = "0d7b2390c5d546e1e442c1283afcbab6e63e03ac8f3033489f7b86cc3de71278"
|
||||
PACKAGE_HASH = "db340f7d117a7cdf4707bbf43f58c0e8307c8ac3813b7bc7dd5ca8a4a47f73b2"
|
||||
FOLDER = Path("/var/tmp/mission-core-node-builds") / IDENTIFIER
|
||||
PACKAGE = "mission-core-node_0.8.18_amd64.deb"
|
||||
TESTS = {
|
||||
"apps/control-station/test/k1ManualControl.test.mjs",
|
||||
"apps/control-station/test/k1SupervisorPresentation.test.mjs",
|
||||
}
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() == 0 or (platform.system(), platform.machine()) != ("Linux", "x86_64"):
|
||||
raise ValueError("Use the unprivileged Ubuntu account")
|
||||
if sys.argv[1:] not in ([], ["--job"]):
|
||||
raise ValueError("Only the fixed N09 recovery is admitted")
|
||||
os.umask(0o077)
|
||||
artifact = Path(sys.argv[0]).resolve()
|
||||
for p in (FOLDER.parent, FOLDER):
|
||||
info = p.lstat()
|
||||
if p.is_symlink() or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||||
raise ValueError("Staging is not private and owned")
|
||||
with zipfile.ZipFile(artifact) as z:
|
||||
patch = json.loads(z.read("patch.json"))
|
||||
if (
|
||||
set(patch["files"]) != TESTS
|
||||
or set(z.namelist()) != {"__main__.py", "patch.json"} | TESTS
|
||||
):
|
||||
raise ValueError("Only the two test files can change")
|
||||
payload = {name: z.read(name) for name in TESTS}
|
||||
if hashlib.sha256(z.read("__main__.py")).hexdigest() != patch["entrypoint_sha256"]:
|
||||
raise ValueError("Entry point changed")
|
||||
for name, data in payload.items():
|
||||
if (
|
||||
len(data) != patch["files"][name]["bytes"]
|
||||
or hashlib.sha256(data).hexdigest() != patch["files"][name]["sha256"]
|
||||
):
|
||||
raise ValueError("Test payload changed")
|
||||
output = FOLDER / "output"
|
||||
if digest(FOLDER / "source.json") != SOURCE_HASH or digest(output / PACKAGE) != PACKAGE_HASH:
|
||||
raise ValueError("N09 checkpoint changed")
|
||||
manifest = json.loads((FOLDER / "source.json").read_text())
|
||||
repo = FOLDER / "source" / manifest["repository"]
|
||||
if not sys.argv[1:]:
|
||||
with (FOLDER / "build.lock").open("a") as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if (FOLDER / "result.tar.gz").exists():
|
||||
raise ValueError("Recovery already completed")
|
||||
subprocess.run(
|
||||
[
|
||||
"/usr/bin/systemd-run",
|
||||
"--user",
|
||||
"--collect",
|
||||
"--wait",
|
||||
"--pipe",
|
||||
"--unit=mission-core-node-core-resume-" + IDENTIFIER,
|
||||
"--property=MemoryMax=3G",
|
||||
"--property=CPUQuota=150%",
|
||||
"--property=TasksMax=256",
|
||||
"--property=RuntimeMaxSec=600",
|
||||
"--property=KillMode=control-group",
|
||||
"--property=Nice=10",
|
||||
"/usr/bin/python3",
|
||||
str(artifact),
|
||||
"--job",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
with tarfile.open(FOLDER / "result.tar.gz", "w:gz") as archive:
|
||||
for path in sorted(output.iterdir()):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError("Unexpected result")
|
||||
archive.add(path, arcname=path.name, recursive=False)
|
||||
archive.add(FOLDER / "report.json", arcname="build-report.json", recursive=False)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result": str(FOLDER / "result.tar.gz"),
|
||||
"sha256": digest(FOLDER / "result.tar.gz"),
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
group = next(
|
||||
x.split(":", 2)[2]
|
||||
for x in Path("/proc/self/cgroup").read_text().splitlines()
|
||||
if x.startswith("0::")
|
||||
)
|
||||
control = Path("/sys/fs/cgroup") / group.lstrip("/")
|
||||
cpu, period = map(int, (control / "cpu.max").read_text().split())
|
||||
if (
|
||||
int((control / "memory.max").read_text()) != 3 * 1024**3
|
||||
or cpu / period > 1.5
|
||||
or int((control / "pids.max").read_text()) > 256
|
||||
):
|
||||
raise ValueError("Resource limits not enforced")
|
||||
for name, expected in manifest["files"].items():
|
||||
path = FOLDER / "source" / name
|
||||
if path.is_symlink() or digest(path) != expected["sha256"]:
|
||||
raise ValueError("Original input changed: " + name)
|
||||
if digest(output / "qualification.json") != REPORT_HASH:
|
||||
raise ValueError("Qualification checkpoint changed")
|
||||
report = json.loads((output / "qualification.json").read_text())
|
||||
if report["state"] != "error" or report["jobs"][-1]["id"] != "core-tests":
|
||||
raise ValueError("Only the observed N09 test failure can be resumed")
|
||||
with (output / "qualification-before-n09-resume.json").open("xb") as f:
|
||||
f.write((output / "qualification.json").read_bytes())
|
||||
for name, data in payload.items():
|
||||
(repo / name).write_bytes(data)
|
||||
(output / "test-contract-patch.json").write_text(json.dumps(patch, indent=2))
|
||||
tool = FOLDER / "toolchains/node-v24.9.0-linux-x64"
|
||||
env = {
|
||||
"PATH": str(tool / "bin") + ":/usr/bin:/bin",
|
||||
"LANG": "C.UTF-8",
|
||||
"TMPDIR": str(FOLDER / "temporary"),
|
||||
"NODE_OPTIONS": "--max-old-space-size=2048",
|
||||
"npm_config_cache": str(FOLDER / "npm-cache"),
|
||||
"npm_config_audit": "false",
|
||||
"npm_config_fund": "false",
|
||||
"npm_config_update_notifier": "false",
|
||||
}
|
||||
started = time.monotonic()
|
||||
report.update(
|
||||
state="running",
|
||||
recovered_from=REPORT_HASH,
|
||||
recovery_started_at=datetime.now(UTC).isoformat(),
|
||||
recovery_monotonic_started=started,
|
||||
)
|
||||
|
||||
def save():
|
||||
(output / "qualification.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
def run(name, args, timeout):
|
||||
job = {"id": name, "started_at": datetime.now(UTC).isoformat(), "state": "running"}
|
||||
report["jobs"].append(job)
|
||||
save()
|
||||
begin = time.monotonic()
|
||||
with (
|
||||
(output / (name + ".stdout")).open("wb") as out,
|
||||
(output / (name + ".stderr")).open("wb") as err,
|
||||
):
|
||||
result = subprocess.run(
|
||||
args,
|
||||
cwd=repo / "apps/control-station",
|
||||
env=env,
|
||||
stdout=out,
|
||||
stderr=err,
|
||||
timeout=timeout,
|
||||
)
|
||||
job.update(
|
||||
exit_code=result.returncode,
|
||||
state="complete" if result.returncode == 0 else "error",
|
||||
duration_seconds=time.monotonic() - begin,
|
||||
)
|
||||
save()
|
||||
if result.returncode:
|
||||
raise RuntimeError(name + " failed")
|
||||
|
||||
try:
|
||||
run(
|
||||
"core-tests-n09-contract",
|
||||
[
|
||||
str(tool / "bin/node"),
|
||||
"--test",
|
||||
"--test-concurrency=1",
|
||||
"--test-skip-pattern=^native RRD crosses many RTC fragments "
|
||||
"and reaches decoder once, byte-for-byte$",
|
||||
*[str(p) for p in sorted((repo / "apps/control-station/test").glob("*.test.mjs"))],
|
||||
],
|
||||
300,
|
||||
)
|
||||
run(
|
||||
"core-build-n09",
|
||||
[
|
||||
str(tool / "bin/node"),
|
||||
str(tool / "lib/node_modules/npm/bin/npm-cli.js"),
|
||||
"run",
|
||||
"build",
|
||||
],
|
||||
240,
|
||||
)
|
||||
roots = {
|
||||
"core-dist.tar.gz": repo / "apps/control-station/dist",
|
||||
"design-guideline-catalog.tar.gz": FOLDER
|
||||
/ "source/NODEDC_DESIGN_GUIDELINE/apps/catalog/dist",
|
||||
}
|
||||
for name, root in roots.items():
|
||||
with tarfile.open(output / name, "w:gz") as archive:
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_file() and not path.is_symlink():
|
||||
archive.add(path, arcname=str(path.relative_to(root)), recursive=False)
|
||||
report.update(
|
||||
state="complete",
|
||||
finished_at=datetime.now(UTC).isoformat(),
|
||||
recovery_duration_seconds=time.monotonic() - started,
|
||||
memory_peak_bytes=int((control / "memory.peak").read_text()),
|
||||
)
|
||||
report["artifacts"] = {
|
||||
p.name: {"bytes": p.stat().st_size, "sha256": digest(p)}
|
||||
for p in [output / PACKAGE, *[output / n for n in roots]]
|
||||
}
|
||||
save()
|
||||
except Exception:
|
||||
report.update(state="error", finished_at=datetime.now(UTC).isoformat())
|
||||
save()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -55,11 +55,11 @@ Nice=10
|
||||
CONF
|
||||
systemctl daemon-reload
|
||||
systemctl restart postgresql@16-ndcmonitor.service
|
||||
runuser -u postgres -- psql -X -v ON_ERROR_STOP=1 -h /run/mission-core-monitor-db -p 5433 -d postgres <<'SQL'
|
||||
runuser -u postgres -- psql -X -P pager=off -v ON_ERROR_STOP=1 -h /run/mission-core-monitor-db -p 5433 -d postgres <<'SQL'
|
||||
SELECT 'CREATE ROLE "mission-core-monitor" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE' WHERE NOT EXISTS(SELECT FROM pg_roles WHERE rolname='mission-core-monitor') \gexec
|
||||
SELECT 'CREATE DATABASE mission_core_monitor OWNER "mission-core-monitor"' WHERE NOT EXISTS(SELECT FROM pg_database WHERE datname='mission_core_monitor') \gexec
|
||||
SQL
|
||||
runuser -u postgres -- psql -X -v ON_ERROR_STOP=1 -h /run/mission-core-monitor-db -p 5433 -d mission_core_monitor -c 'CREATE EXTENSION IF NOT EXISTS timescaledb'
|
||||
runuser -u mission-core-monitor -- psql -X -v ON_ERROR_STOP=1 -h /run/mission-core-monitor-db -p 5433 -d mission_core_monitor -f /usr/lib/mission-core-node/monitor/schema.sql
|
||||
runuser -u postgres -- psql -X -P pager=off -v ON_ERROR_STOP=1 -h /run/mission-core-monitor-db -p 5433 -d mission_core_monitor -c 'CREATE EXTENSION IF NOT EXISTS timescaledb'
|
||||
runuser -u mission-core-monitor -- psql -X -P pager=off -v ON_ERROR_STOP=1 -h /run/mission-core-monitor-db -p 5433 -d mission_core_monitor -f /usr/lib/mission-core-node/monitor/schema.sql
|
||||
systemctl enable mission-core-node-monitor.service
|
||||
systemctl restart mission-core-node-monitor.service
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {xgridsK1SensorUi} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
|
||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {request} from './api';
|
||||
const transport:SensorTransport={localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))};
|
||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi,insta360X4SensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"esModuleInterop": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@rerun-io/web-viewer": [
|
||||
"node_modules/@rerun-io/web-viewer"
|
||||
],
|
||||
"react": [
|
||||
"node_modules/@types/react/index.d.ts"
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
esbuild: { jsx: "automatic" },
|
||||
// Design Guideline packages are linked during development. Their own React
|
||||
// must never become a second hook dispatcher in the portable production bundle.
|
||||
resolve: { alias: {"@mission-core/sensor-sdk": fileURLToPath(new URL("../../../packages/sensor-ui/src/pluginSdk.ts", import.meta.url))}, dedupe: ["react", "react-dom", "@nodedc/ui-react"] },
|
||||
resolve: { alias: {"@mission-core/sensor-sdk": fileURLToPath(new URL("../../../packages/sensor-ui/src/pluginSdk.ts", import.meta.url))}, dedupe: ["react", "react-dom", "@nodedc/ui-react", "@rerun-io/web-viewer"] },
|
||||
optimizeDeps: { exclude: ["@rerun-io/web-viewer"] },
|
||||
build: { target: "esnext", rollupOptions: { input: {
|
||||
app: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||
|
||||
@@ -50,8 +50,10 @@ UGV/UAV описывают платформу, а не способность а
|
||||
принимает приглашения; только mTLS heartbeat/unpair.
|
||||
- Tailscale — заменяемый IP-транспорт. В протоколе нет его API, идентичности,
|
||||
публичного relay, cloud rendezvous или обязательного внешнего сервера.
|
||||
- Адреса фиксируются при привязке. При смене подсети/IP нужна новая привязка.
|
||||
Автоматический поиск нового адреса Core и маршрутизация между сетями отсутствуют.
|
||||
- Исходный Node0.5.x фиксировал адреса при привязке без автоматического
|
||||
восстановления. Расширение Node0.8.19 ниже сохраняет существующую привязку
|
||||
при переносе Core на доступный tailnet IPv4. Оно не вводит поиск неизвестных
|
||||
устройств или произвольную маршрутизацию между сетями.
|
||||
- Подключение K1 остаётся отдельной задачей: только Wi-Fi Bridge к общей LAN.
|
||||
Старый локальный Quick Connect остаётся лабораторным legacy-сценарием.
|
||||
|
||||
@@ -178,3 +180,62 @@ loopback-туннель и штатный одноразовый вход). Вс
|
||||
чтении старого paired/revoked-состояния; его срок UI показывает только пока оно
|
||||
открыто. Новое приглашение сбрасывает время связи предыдущей пары. Исправление
|
||||
внесено в пакет и регрессионный тест, а не в настройки тестовой ОС.
|
||||
|
||||
## Восстановление адреса — Node0.8.19 / NET02, 10.09.2026
|
||||
|
||||
Причина изменения: обе машины находились в Tailscale, но прикладная привязка
|
||||
сохранила LAN IPv4 Core. DHCP сменил локальный адрес, heartbeat прекратился.
|
||||
Наличие Tailscale не перенаправляет сохранённый LAN URL. Новая версия сортирует
|
||||
доступные адреса Node с предпочтением диапазона100.64/10; существующий выбор
|
||||
LAN оператором сохраняется. При известном tailnet адресе парный канал Core
|
||||
автоматически переходит на этот транспорт.
|
||||
|
||||
Paired Node слушает только первый собственный доступный tailnet IPv4:8781.
|
||||
Это endpoint восстановления существующей пары, а не приглашение. TLS1.3
|
||||
требует client certificate от сохранённого Core CA и точное совпадение
|
||||
публичного ключа клиента с core_id. Клиентские сертификаты других Node этого
|
||||
же CA не дают полномочий Core. На каждый запрос заново проверяется текущая
|
||||
phase/binding/Core identity, включая уже установленные TLS-сессии после отзыва.
|
||||
Bootstrap по приглашению сохраняет прежний отдельный lifecycle.
|
||||
|
||||
Core пробует максимум два tailnet IPv4 из последнего аутентифицированного
|
||||
inventory именно этого Node, с интервалом не менее30s на аппарат. Нет API
|
||||
Tailscale, peer inventory, сканирования сети, DNS и нового доверия по IP.
|
||||
Самоподписанный TLS сертификат Node проверяется по сохранённому node_id,
|
||||
подписи и сроку до первого application request. Core предъявляет собственный
|
||||
клиентский leaf, подписанный прежним CA, с прежним Core public key.
|
||||
|
||||
NET03: Subject клиентского leaf — `Mission Core channel recovery`, отличается
|
||||
от Subject CA. При одинаковых Subject/SPKI и отсутствии SAN Go x509 считает
|
||||
leaf/CA циклом цепочки и возвращает UnknownAuthorityError. Issuer и ключи
|
||||
остаются прежними. Регрессионный тест проверяет фактический Python profile;
|
||||
versioned `packaging/check_channel_certificates.py` проверяет синтетический
|
||||
Python output через pinned Go на Ubuntu: старый profile отклонён, исправленный
|
||||
принят, публичный ключ сохранён. Ослабление TLS-проверок не требуется.
|
||||
|
||||
`POST /v1/channel/inspect` возвращает schema
|
||||
`missioncore.node-channel-recovery/v1`, node_id/core_id/binding_id,
|
||||
endpoint/endpoint_revision. После проверки этих полей Core открывает8782
|
||||
на собственном tailnet source IP этого соединения. `POST /v1/channel/migrate`
|
||||
содержит ту же schema/binding_id, expected_endpoint/expected_revision и новый
|
||||
endpoint. Node допускает только HTTPS:8782 на точном tailnet source IP
|
||||
аутентифицированного Core. Сохранение атомарно; при совпадении старого состояния
|
||||
изменяются только endpoint и монотонная endpoint_revision. Ключи, CA, клиентский
|
||||
сертификат, vehicle/node/device identities не заменяются. Потеря ответа требует
|
||||
нового inspect; старый compare-and-swap возвращает409.
|
||||
|
||||
Только последующий обычный mTLS heartbeat на новом адресе переводит аппарат
|
||||
в online и обновляет endpoint в Core. Core сверяет endpoint с реальным
|
||||
listener address и revision; старые heartbeat не могут вернуть прежний адрес.
|
||||
Node также не применяет результаты запросов, отправленных до смены endpoint.
|
||||
Хранилище pairing/v1 остаётся совместимым, новое поле revision отсутствует
|
||||
у старых записей и трактуется как0. TLS listener Node обновляет сертификат
|
||||
каждые12h; обработка соединений/JSON/таймауты сохраняют ограниченный бюджет.
|
||||
|
||||
Границы: если Node не имеет ранее известного доступного tailnet IPv4, Core не
|
||||
угадывает новый адрес. Истечение client certificate не обходится recovery.
|
||||
Одновременная смена tailnet адреса/идентичности обоих узлов не квалифицирована.
|
||||
Отсутствие Tailscale не ломает существующее LAN pairing, но автоматическая
|
||||
миграция LAN→другой LAN этим расширением не заявляется. Установка и аппаратная
|
||||
приёмка NET02 фиксируются отдельно в installation ledger; наличие кода и
|
||||
синтетических тестов само по себе не означает восстановления Rover006.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Insta360 X4: видео, управление камерой и расширяемый host устройств
|
||||
|
||||
Дата: 08.09.2026. Статус: реализация host/подготовки начата; аппаратная приёмка не начата. Область управления расширена последующим указанием владельца.
|
||||
Основание: текущие указания владельца, MISSIONCOR-76/3/5 и канонический checkout `main`, исходный HEAD `54a85fdf5005803a7834482b7516ebe6b6fb142b`.
|
||||
Это план следующей реализации, а не разрешение выполнить команды из присланных исследовательских текстов.
|
||||
|
||||
## 1. Результат, который требуется владельцу
|
||||
|
||||
Пользователь подключает X4 к Ubuntu-борту, видит отдельное устройство, нажимает инициализацию, получает прогресс развёртывания встроенного профиля, подтверждение получения изображения и живое видео. Тот же сценарий работает локально в Node и удалённо в Mission Core через существующее сопряжение. Установка и подготовка не требуют консоли оператора.
|
||||
|
||||
Последующее указание владельца расширило задачу: кроме несшитого просмотра нужны настройки камеры, фото, старт/стоп записи и состояние записи, каталог/получение файлов через возможности конкретной X4. Preview и запись на камере — отдельные операции и состояния; закрытие viewer не должно останавливать запись. X4 не подаётся в AI Worker, perception, SLAM, планирование или управление ровером. Сшивка отложена. Удаление/форматирование носителя и прошивка не выполняются в рамках проверки подключения. Существующие записи K1/D455 сохраняют свои контракты.
|
||||
|
||||
Host поддерживает множество производителей и несколько экземпляров одной модели. Четыре D455 — четыре независимые сущности, а не четыре aliases одной камеры. Сотни моделей в каталоге и число одновременно работающих потоков — разные масштабы. Ограничение активных потоков определяется ресурсами конкретного борта; наличие ограничения не должно скрывать остальные устройства или ломать heartbeat.
|
||||
|
||||
## 2. Нулевой baseline и проверенные основания
|
||||
|
||||
- Владелец подтвердил: по X4 выполнено только физическое подключение; SDK ещё не получали. Камера подключена без аккумулятора. Недостаток питания предполагается владельцем, но не измерен.
|
||||
- Read-only проверка 08.09 в 08:53:35 UTC: Ubuntu-ядро `7.0.0-31-generic`, x86_64; Node `0.8.16`, K1 `0.1.14`; их службы, D455, collector и PostgreSQL active/running. X4: USB `2e1a:0002`, 5000M, vendor-specific interface. Это не SDK discovery, не firmware read и не проверка кадров.
|
||||
- В канонических `apps`, `plugins`, `src`, `packages`, `tests`, `config` реализации X4 не найдено. Поиск имён архивов SDK в Downloads/Desktop не нашёл подходящего дистрибутива; это не доказательство отсутствия файла на всех дисках.
|
||||
- В Ops нет отдельной найденной карточки по запросу `Insta360`. MISSIONCOR-76 сохраняет архитектурный baseline; уточнения владельца и дальнейшие результаты не должны молча переписывать старые требования.
|
||||
|
||||
Официальные материалы повторно прочитаны 08.09.2026:
|
||||
|
||||
| Основание | Что оно даёт плану |
|
||||
|---|---|
|
||||
| [Desktop SDK overview](https://insta360develop.github.io/Insta360-Developer_Docs/en/x/desktop/guide/) | X4 входит в Camera SDK; Linux x86_64 описан для Ubuntu 22.04. Нашу 24.04 проверяем отдельно. Media SDK 3.x требует discrete GPU и не нужен первому этапу. |
|
||||
| [Camera API](https://insta360develop.github.io/Insta360-Developer_Docs/en/x/desktop/camera/) | USB Android mode, descriptor с serial/firmware, live callbacks и фактический codec. Документ рекомендует 1920×960 preview; описывает внутренний HTTP service port и Close перед rediscovery. Есть API in-camera stitching, но применимость к live X4 требует проверки. |
|
||||
| [Получение SDK](https://onlinemanual.insta360.com/developer/en-us/resource/sdk) | SDK выдаётся после заявки. Получаем точный архив, документы поставки и фиксируем hash вместо загрузки изменяемого latest при инициализации. |
|
||||
| [Питание камеры](https://onlinemanual.insta360.com/developer/en-us/resource/camera) | Указано стабильное питание 5V/3A; X4 поддерживает USB power-on, но USB power-off не обещан. Эти сведения не измеряют питание нашего порта. |
|
||||
|
||||
Тексты из вложений — гипотезы и ссылки, а не аппаратные результаты. Оценка «95% готово», обязательность GStreamer, безусловный HEVC passthrough, конкретная пропускная способность и пригодность `uhubctl` не приняты как факты.
|
||||
|
||||
## 3. Что необходимо изменить в существующем коде
|
||||
|
||||
| Область | Фактическая привязка сейчас | Планируемая граница |
|
||||
|---|---|---|
|
||||
| `apps/node-agent/internal/node/sensors.go` | ID regex только rsd455/k1, USB VID/PID D455, один D455 socket, фиксированный prepare unit, общий preparation report и блокировка | Реестр моделей и экземпляров; маршрут по device binding; job по profile revision; проверка выбранного экземпляра |
|
||||
| `apps/node-agent/sensors/server.py` | Уже есть словарь нескольких Device и отдельные session/state, но общий SDK context/process, общий Peers | Сохранить идентичности D455; изолировать аппаратные workers экземпляров и их сбои |
|
||||
| `apps/node-agent/sensors/device.py` | Состояние и записи уже разделены по USB serial-derived ID | Не пересоздавать IDs, имена, конфигурацию и каталоги записей при миграции |
|
||||
| `src/k1link/fleet/sensors.py` | До 16 devices, общий полный sensor state до 256 KiB, фиксированный action allowlist | Версионированные компактные inventory pages и capabilities; подробности по запросу; сохранённые bounded transport и команды |
|
||||
| `packages/sensor-ui/src/contracts.ts`, `SensorWorkspace.tsx` | Один preparation; прогресс предполагает 5 системных шагов плюс frame verify; D455 — fallback без kind | Общий контракт jobs/steps, явный model contribution, состояния каждого device |
|
||||
| `internal/node/local_preview.go` | HTTP carrier и capacity завязаны на K1 | Маршрут по выданному media lease и устройству, без K1-specific выбора в host |
|
||||
| D455 `media.py` | RGB frames → encode, 15 Hz, 4 peers на весь worker | Отдельные media adapters и квоты на экземпляр/борт; encoded X4 не прогонять автоматически через D455 RGB path |
|
||||
| `plugins/xgrids-k1/packaging`, Node package hooks | Есть образец отдельного пакета; общие Node upgrades могут останавливаться на guards и затрагивать K1 | Сохранить поведение K1; новый X4 package/prepare не перезапускает другие device runtimes |
|
||||
|
||||
Расширение host не означает переписывание всей платформы или dynamic marketplace. Начинаем с локально установленных, проверяемых descriptors и текущего Plugin SDK. Vendor-specific API остаётся в своём plugin. Новые обязательные несовместимые wire semantics получают новую явную версию и совместную проверку Core/Node.
|
||||
|
||||
## 4. Архитектура моделей и экземпляров
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Core[Mission Core · выбранный аппарат] -->|намерения и signaling| Node[Node · broker и журнал]
|
||||
Local[Локальное окно Node] --> Node
|
||||
Node --> Registry[Реестр профилей и физических экземпляров]
|
||||
Registry --> X4A[X4 A · SDK worker]
|
||||
Registry --> X4B[X4 B · SDK worker]
|
||||
Registry --> RS[Другие workers · D455 A / D455 B / K1]
|
||||
X4A --> Media[Media adapter экземпляра]
|
||||
X4B --> Media
|
||||
Media -->|WebRTC| Core
|
||||
Media -->|локальный HTTP carrier| Local
|
||||
```
|
||||
|
||||
Три отдельные сущности:
|
||||
|
||||
1. **Пакет/профиль модели**: plugin ID, модели, платформы, точная runtime revision, разрешённый USB match, SDK ABI, executable/unit, capabilities, шаги установки и проверки. Descriptor устанавливается доверенным пакетом; UI не передаёт произвольный executable, shell, URL или filesystem path.
|
||||
2. **Экземпляр камеры**: стабильный device ID, модель, подтверждённая identity, имя, configuration revision, USB aliases и история подготовки. Serial не равен номеру порта. При отсутствии надёжного serial identity остаётся provisional; не склеивать камеры по одинаковым VID/PID или имени. USB discovery и SDK descriptor связываются по точному выбранному transport; нельзя использовать `list[0]`.
|
||||
3. **Рабочий сеанс**: runtime instance/generation, device session, preview session, peer leases и journal операций. Replug/restart создаёт новый сеанс прежнего устройства. Старые команды и peer IDs не получают полномочия нового сеанса.
|
||||
|
||||
Установка SDK выполняется один раз для profile revision, а проверка и настройка — для каждого экземпляра. Если два UI инициализируют две одинаковые X4, они используют один installation job и затем две адресные проверки. Успех X4 A не инициализирует X4 B. Повторная установка не является обязательной при смене USB-порта.
|
||||
|
||||
Целевой hardware worker — отдельный процесс на экземпляр. Он получает только выбранный device binding и собственные ресурсы. Плагин координирует discovery, чтобы SDK enumeration одного процесса не захватывала соседнюю камеру. Зависание/краш X4 A должно завершать её сеанс, а не весь Node или X4 B. Общая поломка USB-контроллера остаётся физическим общим отказом.
|
||||
|
||||
D455 переводится на эту границу без смены идентичностей и raw-format. Поставка X4 может пройти раньше полной аппаратной проверки 2–4 D455; в таком случае самостоятельная работа нескольких D455 явно остаётся открытым критерием, а не считается доказанной словарём Device.
|
||||
|
||||
## 5. Путь установки: обязательная воспроизводимость с первого изменения
|
||||
|
||||
**Правило владельца: никакой нужной X4 настройки Ubuntu вне поставляемого installer/prepare workflow, включая первый инженерный запуск.** Сначала исправляется артефакт, затем этот же артефакт применяется. Ручной workaround с обещанием «потом перенести» запрещён. Подробный учёт — [журнал подготовки](07_INSTA360_X4_INSTALLATION_LEDGER.md).
|
||||
|
||||
Предлагаемый optional package: `mission-core-insta360-x4`. Внутри: наш скомпилированный adapter, разрешённые vendor libraries, exact dependency manifest, лицензии/notices, private media runtime, модельный descriptor, udev/unit/helpers, migration/repair logic и public provenance. Camera SDK нужен для выбранного USB-пути управления; один Webcam Mode недостаточен. Media SDK первого этапа не нужен. Состав vendor redistribution проверяется по полученному SDK. Если поставка библиотеки в нашем пакете не разрешена, автоматический лицензированный импорт из UI становится отдельным решением до выпуска; скрытой ручной копии `.so` не будет.
|
||||
|
||||
Получение vendor SDK и компиляция относятся к сборке артефакта. На борту нет компилятора, npm, исходного checkout, системного pip или ручного LD_LIBRARY_PATH. Минимальные OS libraries определяем по ABI/DT_NEEDED полученного ELF, а не копируем инструкции сборки SDK с `*-dev` пакетами. Их полная зависимость объявляется в пакетах и manifest; установщик разрешает её сам.
|
||||
|
||||
Поддерживаемый clean-image фиксируется точными Ubuntu 24.04 Desktop amd64 ISO/build/hash и источниками пакетов. Vendor payload и X4 runtime находятся в поставке. Отсутствующие OS dependencies либо входят в release bundle, либо устанавливаются штатным, явным этапом из зафиксированных доверенных источников. Не заявлять offline-режим без полной offline-проверки. Наличие библиотеки на нынешнем Mini не заменяет её объявления.
|
||||
|
||||
Стадии продукта:
|
||||
|
||||
| Стадия | Действие и ответственность |
|
||||
|---|---|
|
||||
| Установка release | Согласованные base Node и optional packages; готовые assets обеих UI, зависимости, provenance. Позволяет обнаружить поддерживаемую X4 до загрузки SDK. |
|
||||
| Первое подключение | Read-only OS discovery → отдельная строка. Неподходящий USB mode/нет profile payload показываются явно. Сам hotplug не запускает установку. |
|
||||
| «Инициализировать» | Durable operation → lock profile revision → проверка ОС/архитектуры/места/целостности → атомарное развёртывание → адресный доступ → запуск worker выбранной камеры → SDK identity/capabilities → короткая проверка изображения. |
|
||||
| Проверка кадров | Отдельный ограниченный live-preview сеанс только выбранной камеры. Успех означает реальные декодируемые кадры, а не запущенный процесс или callback без валидного видео. Инициализация сама не запускает запись на SD; запись начинается отдельной явной командой пользователя. |
|
||||
| Готовность | Сохранены profile revision и результат этой камеры; показываются текущие presence/health отдельно от исторической успешной проверки. Повторная подготовка доступна в настройках. |
|
||||
| Repair/update | Новая immutable runtime revision, dry preflight, точный affected set, failure report, rollback активного указателя и сохранность identity/state. Установка второй X4 не перезапускает первую. |
|
||||
|
||||
Jobs имеют run ID, profile ID/revision, device scope, фактические шаги, timestamps, progress и terminal status. Две UI наблюдают один job. Installation success и failed device verification сохраняются раздельно: выключенная камера не превращает корректно установленный runtime в «не установлен».
|
||||
|
||||
Все privileged actions имеют фиксированные allowlists и принадлежат shipped helper. Node и SDK не работают root. Доступ к USB/IIO выдаётся адресно; обновление доступа при replug выполняет тот же установленный workflow. SDK HTTP server не публикуется в LAN: предпочтительно изолированный network namespace SDK worker, media adapter с UDS вне него. Фактическую совместимость этого разделения проверяет первый SDK-этап. Если namespace невозможен для exact SDK, нужен проверенный пакетный способ ограничения listener до допуска Open; широкое открытие порта не является workaround.
|
||||
|
||||
SDK service ports/namespace и reservation принадлежат supervisor. Нельзя всем экземплярам выдавать один конфликтующий порт в общем namespace. Все экземпляры одного runtime защищены от обновления его файлов во время работы; новая revision сначала staging. Global Node/API migration, если потребуется, оформляется отдельным согласованным idle update всех затронутых компонентов.
|
||||
|
||||
Физические условия отдельно: стабильное питание и USB control mode камеры. Батарея пока вынута; первый streaming test целесообразно выполнять после обеспечения питания. Приложение должно объяснять требуемый режим камеры. Установщик Ubuntu не может физически вставить аккумулятор. Управление VBUS, `uhubctl`, custom firmware и power-cycle watchdog не нужны первому этапу user view и не выполняются как «проверка».
|
||||
|
||||
## 6. Видео: конкретный путь и предел доказательства
|
||||
|
||||
Выбранный начальный вариант: C++ worker вокруг официального Camera SDK → ограниченный encoded-video IPC → media adapter на существующем стеке aiortc/PyAV → WebRTC RTP video в удалённом Core. Это проектное решение до hardware probe, а не доказанный X4 pipeline.
|
||||
|
||||
Локально в установленном Node сохраняется совместимый с текущим WebKit HTTP/fMP4 carrier из той же исходной видеосессии. Локальный просмотр — обязательная приёмка: наличие remote WebRTC не доказывает local WebKit. Из K1 извлекаются только действительно общие framing/lease/lifecycle части; X4 не импортирует K1 protocol, RRD, camera gateway или physical command ledger.
|
||||
|
||||
В поставляемом сейчас `aiortc 1.14.0` проверен код: RTCRtpSender принимает encoded Packet и вызывает encoder.pack; H264.pack выполняет packetization. Codec list содержит VP8/H264, HEVC отсутствует. Поэтому:
|
||||
|
||||
- H.264 passthrough — основной кандидат только после проверки реального X4 codec/profile/level/SPS/PPS/IDR и SDP compatibility. Штатные advertised H264 profiles aiortc нельзя подменять строкой для несовместимого camera bitstream.
|
||||
- H.265 не объявляется автоматически совместимым с этим WebRTC. Если X4 не даёт подходящий H264, проверяем один общий bounded transcode на источник и измеряем Mini; transcode на каждого зрителя не допускается как default. При неприемлемой задержке/нагрузке feasibility остаётся открытой, а не обходится подключением AI Worker.
|
||||
- Формат callback, единицы timestamp, ownership/lifetime памяти, stream_index и структура access units проверяются по exact SDK и реальному короткому потоку. Данные копируются/передаются до истечения vendor buffer; SDK callback не ждёт медленного браузера.
|
||||
- Для каждого source — monotonic timeline/epoch, корректные PTS/time_base/RTP clock, bounded bytes/age, известная политика keyframes и восстановления. В текущем encoded Packet пути aiortc PLI не производит новый keyframe камеры автоматически; необходим отдельный проверенный механизм либо ожидание очередного IDR с ограничением времени. Не перезапускать общий SDK stream из-за одного нового peer.
|
||||
- Медленный peer отключается/пересинхронизируется независимо; очередь не превращается в растущую задержку. Нельзя выбрасывать произвольные inter frames и считать декодирование восстановленным без IDR.
|
||||
- Исходный кадр/dual-fisheye layout и доступные lens controls проверяются. Два объектива не должны появляться как две физические камеры. Сшитая панорама в v1 не требуется.
|
||||
|
||||
Media ownership: один live producer на физическую X4, несколько разрешённых подписчиков. Открытие detail само не пишет настройки камеры; явное начало просмотра создаёт preview-only session. Второй зритель присоединяется к ней. Закрытие одного peer не останавливает поток остальных. Для X4 предлагается lease-policy завершать только preview producer после ухода последнего зрителя и короткого grace period; точное время задаётся профилем и проверяется. Она не вызывает StopRecording, не меняет lifecycle K1/D455 и не прекращает чужую/камерную запись. Проверка при инициализации имеет отдельный bounded lease и всегда освобождает свои ресурсы.
|
||||
|
||||
Команды сохраняют operation ID/idempotency/session/generation. Unknown после сбоя не превращается в новый аппаратный START/STOP. Reconnect читает текущий binding/состояние и восстанавливает только допущенный preview intent. Активная неизвестная camera-side запись не останавливается ради проверки кадров.
|
||||
|
||||
В новом descriptor действия просмотра обозначаются явно, например `preview.start`/`preview.stop`, с проверкой согласованного action set в Core и Node. Их точные wire IDs фиксируются в P1. Существующие `start`/`stop` записи K1/D455 не переопределяются ради X4; `offer` остаётся media signaling. Операция «Инициализировать» отдельно разрешает только свой ограниченный тест кадров.
|
||||
|
||||
Начальные измеряемые цели, пока не гарантии: warm first frame ≤5 секунд, p95 glass-to-glass ≤500 мс в LAN, отсутствие накопления задержки за 30 минут. Запрошенный профиль-кандидат — 1920×960/30, допустимые bitrate/FPS определяются hardware probe. Измеряем p50/p95/max, фактические displayed FPS, skips/gaps, CPU/RAM/USB/network, thermal/power events. Glass-to-glass измеряется общим визуальным временем, а не SDK-to-network временем или одним RTT. Tailnet получает отдельное измерение. Пропавшие кадры не показываются как свежий эфир; initial stale threshold ≤2 секунд — также проверяемая цель.
|
||||
|
||||
Сшивка может позже выполняться в самой X4, если exact SDK/FW подтверждают нужный live output, либо на стороне просмотра. Оба варианта требуют проверки качества и задержки; browser projection без калиброванной сшивки не называется полноценным stitching. Это не dependency v1 и не задача существующего AI Worker.
|
||||
|
||||
## 7. Масштаб и независимость
|
||||
|
||||
Для большого inventory вводится versioned summary protocol: pages/cursor, snapshot revision, завершённость снимка и tombstones; подробные profiles/options по запросу. Сборка нового снимка не смешивает страницы разных ревизий. Потерянные события исправляются resync, удалённые устройства не становятся фантомами. Старый Core не должен молча терять камеры: обязательны capability negotiation и понятное требование совместимой версии.
|
||||
|
||||
Лимиты отдельных сообщений/очередей сохраняются. Control heartbeat, inventory, monitor и media имеют отдельные бюджеты. Не повышать только `16` до `500` и не слать полный набор profiles/recordings каждого устройства в каждом heartbeat. Планирование команд справедливо между экземплярами; перегруженный/неисправный driver не блокирует остальные.
|
||||
|
||||
UI не открывает media peers для всех строк. Каталог и поиск работают с summary pages; поток включается для выбранной камеры. Supervisor учитывает пределы процессов, файлов, памяти, CPU, USB controller и сети. Неактивные сотни descriptors не требуют сотен загруженных SDK workers. Превышение ресурса даёт объяснимый отказ нового live session при сохранении работающих.
|
||||
|
||||
## 8. Интерфейс в существующей композиции
|
||||
|
||||
Операторская задача — найти конкретную камеру, подготовить и увидеть происходящее. Сущность — physical device, точка входа — Node → Устройства либо Core → Парк → аппарат → Устройства. Используются общие SensorWorkspace и transport adapters.
|
||||
|
||||
Выбран plugin contribution в текущем списке/detail. Рассмотренный отдельный root «Insta360» отклонён: он создаёт вторую навигацию той же сущности. Новых продуктовых разделов/специальных LAB/AI панелей не требуется. Domain content относится к существующему slot; будущая панорама — отдельный view mode после приёмки.
|
||||
|
||||
Используем существующие DG ResourceRow/ResourceList, ProgressBar, SettingsCard, IconButton, Button, StatusBadge и поля. Состояния preview и записи показываются независимо. Состояния: обнаружена/нужен режим/не установлен профиль/подготовка/проверка/готова/получаем видео/эфир/нет свежих кадров/отключена/ошибка/нет ресурсов. Показываются действия и результат, а названия Linux-пакетов/SDK/internal error codes остаются в engineering details и отчёте.
|
||||
|
||||
Готовый runtime не означает проверенные кадры каждой камеры. Индикатор связи не означает запись. Повторная инициализация — в настройках, первоначальная — в строке. Прогресс выводится из job steps конкретного профиля без D455-specific 5/6 и без общей полосы, присвоенной соседнему устройству.
|
||||
|
||||
## 9. Декомпозиция и условия перехода
|
||||
|
||||
| Этап | Работа | Проверяемый выход / зависимости |
|
||||
|---|---|---|
|
||||
| P0 · Vendor input | Получить Linux x86_64 SDK, точный архив/hash, headers, license/redistribution, version/FW compatibility сведения | Публичный SDK 2.1.8 получен и зафиксирован по hash; условия распространения и аппаратная совместимость ещё не закрыты. Заявка не подаётся по решению владельца. |
|
||||
| P1 · Host contracts | Registry/model/device/session/jobs; inventory v2/paging; per-instance routing и migration; соответствующие тестовые adapters | 2/4 одинаковых устройства и 500 synthetic summaries без смешения ID, прогресса и command routing. Heavy synthetic run — Worker 006. |
|
||||
| P2 · Первый installable probe | Собрать минимальный X4 package и штатный prepare job со всеми зависимостями до первого SDK запуска на Mini | Clean-image dependency test + установка артефакта; непривилегированные exact-device Open/status/codec и bounded frame verification. P0, минимальные P1 contracts. |
|
||||
| P3 · Изоляция экземпляров | SDK workers, discovery coordination, exclusive ownership, ports/network sandbox, per-device access/replug | Ошибка A не останавливает B; параметры/операции B не попадают A. Fake SDK tests плюс hardware evidence где есть устройства. |
|
||||
| P4 · Media slice | H264 admission/packetization или измеренный fallback, remote WebRTC, local carrier, clocks/backpressure/leases | Реальное несшитое видео одной X4 с измеренной задержкой в обеих UI. P2–P3. |
|
||||
| P4C · Camera control | Фото/видео режимы, настройки по capabilities, старт/стоп SD-записи, состояние/уведомления, каталог и получение файлов; журнал эффектов отдельно от viewer leases | Native adapter написан частично, проверен с synthetic SDK. Реальные SDK calls и полный набор настроек/получение файлов ещё не проверены/не реализованы. |
|
||||
| P5 · Пользовательский путь | Общая инициализация/прогресс/проверка/detail; отдельные управление записью и просмотром; доступные настройки и файлы; stale/errors | Полный GUI-сценарий без shell, включая две UI одной камеры. P1/P4/P4C. |
|
||||
| P6 · D455 migration/regressions | Сохранение IDs/записей; устранение shared process failure domain и per-port IIO переподготовки; K1 regression | Несколько D455 независимы по lifecycle; K1 direct/onboard сохраняет принятый scope. Отсутствующие физические камеры отмечаются как непройденная hardware часть. |
|
||||
| P7 · Release/clean install | Полный артефакт, closure, repair/update/rollback, fault tests и acceptance | Воспроизводимая чистая Ubuntu + реальная X4 + Node/Core; manifest и журнал покрывают каждое изменение. |
|
||||
|
||||
Дополнение 08.09: владелец отказался подавать SDK-заявку и поручил искать публичные форки. Обнаружены и статически проверены публичные SDK-кандидаты; CameraSDK 2.1.8 x86_64 получен из Git LFS, зафиксирован в `plugins/insta360-x4/packaging/sdk-lock.json`. Подробности и ограничения — [аудит источников](08_INSTA360_X4_SDK_SOURCE_AUDIT.md). Наличие файла ещё не закрывает подлинность vendor-сборки, условия распространения, ABI/runtime и hardware gates.
|
||||
|
||||
Критический путь X4: P0 → минимальный P1/P2 → P3/P4/P4C → P5 → P7. Расширяемый host закладывается до X4; первая интеграция включает видео **и управление камерой**. Число моделей в каталоге не является обещанием 500 одновременных потоков на Mini. P6 имеет собственную приёмку и не объявляется завершённым успехом P4.
|
||||
|
||||
## 10. Матрица приёмки
|
||||
|
||||
- [ ] SDK version/hash/ABI/FW/USB mode подтверждены; нет неподтверждённого latest в runtime.
|
||||
- [ ] Чистый Ubuntu образ без прежних Node/X4 зависимостей проходит установку и prepare штатным GUI-путём; описаны требуемые сеть/физические условия.
|
||||
- [ ] Тот же артефакт даёт реальные кадры на Mini; VM-проверка зависимостей отдельно от аппаратной.
|
||||
- [ ] Инициализация из Node и из Core приводит к одному job и точному устройству; два экземпляра не делят verification outcome.
|
||||
- [ ] USB disconnect/replug/другой порт сохраняют identity, имя и runtime readiness; изменяют session; старые команды отвергаются.
|
||||
- [ ] Две X4 либо hardware-equivalent доступная пара проходят независимый Open/stream/stop/crash; подмена только mock явно помечена.
|
||||
- [ ] Две/четыре D455 проходят адресные tests; фактический предел USB/FPS измерен отдельно.
|
||||
- [ ] X4 prepare/retry не прерывает активные K1/D455; update общего runtime не подменяет файлы работающего экземпляра.
|
||||
- [ ] Два зрителя одной X4 не создают два SDK владельца; закрытие одного сохраняет видео второму; уход последнего освобождает bounded preview resources.
|
||||
- [ ] Remote WebRTC и actual native Node WebKit дают живое видео; codec bootstrap, поздний зритель, IDR/PLI и потеря пакетов проверены.
|
||||
- [ ] 30 минут выбранного профиля: измерены latency/FPS/ресурсы и отсутствие неограниченных очередей. Повторения/replug/outage выполняются как отдельные учтённые тесты.
|
||||
- [ ] Source stale/disconnect/plugin crash/Core loss отражаются честно; нет замороженного кадра с признаком живого эфира или повторной команды с unknown исходом.
|
||||
- [ ] Preview stop, закрытие viewer, потеря Core и завершение SDK worker не вызывают `StopRecording`; реальное поведение firmware при потере USB проверено отдельно.
|
||||
- [ ] Старт/стоп записи и фото вызываются только явной адресной командой; deadline, session, durable receipt и notifications защищают от повтора/неверного состояния.
|
||||
- [ ] Settings используют capabilities выбранной камеры и актуальные зависимые значения; успешный setter подтверждён чтением, UI не обещает недоступные поля.
|
||||
- [ ] Каталог и получение файлов доступны с Node/Core; пагинация, файл в процессе записи, прерывание передачи и повтор не повреждают оригинал на SD.
|
||||
- [ ] 500 synthetic descriptors, несколько моделей, paging/resync и переполнение бюджета не теряют fleet connectivity и не тормозят команды выбранного device.
|
||||
- [ ] Сбой install/prepare на каждом шаге имеет результат и штатный retry/repair; повтор безопасен; identity и существующие записи сохраняются.
|
||||
- [ ] Пройден clean upgrade/rollback с документированным affected set. Отдельная копия старого SDK на диске не является rollback механизмом.
|
||||
- [ ] Журнал не содержит ни одной системной предпосылки X4 без соответствующего installer step; доказательства приложены и приватные данные отделены.
|
||||
|
||||
Полная чистая аппаратная приёмка выполняется на отдельном чистом образе/диске или отдельном подходящем компьютере. Стирание текущего рабочего Mini в данный план исполнения не входит. Переустановка на уже подготовленном Mini — тест повторяемости, а не тест чистой ОС.
|
||||
|
||||
## 11. Оставшиеся непроверенные условия
|
||||
|
||||
SDK version/hash теперь известны. Остаются условия распространения, firmware X4, runtime ABI на Ubuntu 24.04 и CPU Mini, control mode и соответствие USB/SDK serial, фактический preview codec/profile/GOP/timestamp, поведение нескольких Camera instances, SDK listener в network namespace, native WebKit, одновременные preview/SD recording, полный набор настроек, стабильность питания и измеренная задержка. Эти пункты превращены в условия приёмки, а не заполнены предположениями.
|
||||
|
||||
Следующее аппаратное действие возможно только после Linux-сборки и подготовки **устанавливаемого** compatibility probe с полным installer ownership. По последующему указанию владельца используем **только имеющийся Ubuntu Mini для сборки и квалификации**. GCC 13 уже есть в baseline; `build_source_artifact.py`/`native_build_entry.py` владеют временным build staging и compile/test/result/cleanup. `build_native.py` не устанавливает runtime и не исполняет SDK. Конечный installer включает скомпилированный binary и не требует GCC на чистой Ubuntu. Worker 006 не является зависимостью X4; сшивка на нём не запускается.
|
||||
|
||||
Заявка поставщику не отправлялась и не планируется без нового указания владельца. Текущий путь — публичный зафиксированный mirror; его получение и границы доказательства описаны отдельно. Точное состояние кода, проверки и оставшаяся работа — [отчёт реализации](09_INSTA360_X4_IMPLEMENTATION_STATUS.md).
|
||||
@@ -0,0 +1,492 @@
|
||||
# Insta360 X4 — журнал исходного состояния и установки
|
||||
|
||||
Начат 08.09.2026. Статус: SDK получен для сборки, исходники host/adapter частично реализованы и проверены локально. Борт остаётся в нулевой точке: только USB enumeration. Связанный [план](06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md).
|
||||
|
||||
## Обязательное правило
|
||||
|
||||
Каждое изменение Ubuntu, влияющее на обнаружение, подключение, SDK или видео X4, выполняется поставляемым installer либо versioned device-preparation job с первого инженерного опыта. Документирование ручного обхода не делает его допустимым. При ошибке меняем артефакт и повторяем его штатную процедуру; не «долечиваем» машину отдельно.
|
||||
|
||||
Правило относится к зависимостям, библиотекам, SDK payload, runtime paths, пользователям/группам, USB/udev/IIO permissions, units/drop-ins, environment, sockets/ports, network isolation, startup, caches, migrations, package-manager actions и repair. На board нельзя вручную внести предпосылку, которой нет в артефакте.
|
||||
|
||||
Read-only диагностика допускается и учитывается. SDK Open/preview/test frames могут иметь аппаратный эффект и не называются read-only только потому, что не пишут файл. Они запускаются через поставленный bounded workflow; профиль теста и cleanup записываются.
|
||||
|
||||
## Учтённые действия
|
||||
|
||||
| ID / UTC | Действие | Результат | Изменения борта / owner step |
|
||||
|---|---|---|---|
|
||||
| X4-Z00 / сообщил владелец 08.09 | Подключение X4 USB к Mini; аккумулятор вынут | Нулевая точка. SDK/инициализацию/видео ещё не выполняли | Физическое действие владельца; не installer action |
|
||||
| X4-Z01 / 08.09 08:53:35 | Read-only SSH: `date -u`, `uname -srmo`, `dpkg-query -W`, `systemctl show`, `lsusb`, `lsusb -t` | Node 0.8.16/K1 0.1.14 установлены; службы активны; kernel 7.0.0-31-generic x86_64; X4 2e1a:0002, vendor-specific, 5000M | Нет изменений. Запросы статуса ОС; SDK не вызывался |
|
||||
| X4-Z02 / 08.09 около 08:54 | GET canonical Core `/api/health` и `/api/v1/fleet` | Core operational; paired board online; K1 idle/connected; X4 driver-backed item отсутствует | Нет изменений. Не выполнялись prepare/START/STOP/scan |
|
||||
| X4-Z03 / 08.09 | Чтение исходников, package scripts, locked manifests и aiortc 1.14.0 wheel | Найдены модельные привязки D455/K1, inventory limit16, H264 Packet path и отсутствие HEVC в текущем codec list | Нет запуска wheel/SDK и нет установки |
|
||||
| X4-Z04 / 08.09 | Чтение официальных Insta360 материалов и вложений; поиск имён SDK в Downloads/Desktop | Штатный SDK путь описан; exact archive не получен; владелец подтвердил нулевую точку | Нет заявки поставщику, загрузки vendor runtime, установки или firmware change |
|
||||
| X4-Z05 / 08.09 | Чтение прямого Ops MCP и подготовка плана/этого журнала/правила AGENTS.md | Сохранены требования user-view, нескольких камер и обязательного installer path | Только документация в checkout; Mini/Core runtime не изменялись |
|
||||
| X4-Z06 / 08.09 09:54:52.795765 | Read-only SSH: чтение sysfs USB metadata и наличия `/sys/class/video4linux` | X4 `2e1a:0002`, exact product `Insta360 X4`, serial присутствует, 5000M, одна interface класса `ff`, kernel driver не привязан; video devices: 0 | Нет изменений. SDK не загружался, serial/derived device hash в Git не помещены |
|
||||
|
||||
Адреса входа, реальные serials, pairing material, токены, ключи, сырые изображения и полные приватные журналы сюда не включаются. В приватном evidence допускаются необходимые фактические идентификаторы; в публичном отчёте используются role/derived ID и hash.
|
||||
|
||||
## Форма записи перед каждым следующим шагом
|
||||
|
||||
Каждая операция получает неизменяемый ID. До исполнения записываются intent, scope и artifact; после — outcome и evidence. Неудачная попытка сохраняется, а повтор получает отдельный attempt, связанный с исходным intent.
|
||||
|
||||
| Поле | Требуемое содержание |
|
||||
|---|---|
|
||||
| ID / причина | Зачем нужен шаг и какой критерий плана он проверяет |
|
||||
| Время | UTC start/end; duration по monotonic clock |
|
||||
| Host baseline | OS image/build/hash, architecture/kernel, existing package/runtime versions; clean либо prepared |
|
||||
| Device scope | Exact model/profile/derived instance; USB mode/link; firmware когда прочитан; privacy-safe identity |
|
||||
| Artifact | Release/package version, source revision, SHA-256, dependency manifest и соответствующий файл installer/helper |
|
||||
| Entry point | GUI action и фиксированный installer step; какой production code path выполняется |
|
||||
| Preconditions | Диск, питание, связь, locks, ABI, активные соседние устройства; expected affected set |
|
||||
| Intended changes | Пакеты/файлы/units/права/env/resources, которые изменяет сам артефакт |
|
||||
| Result | Exit/result state, реальные before/after, verification и границы доказательства |
|
||||
| Repetition | Что произойдёт при повторе того же job, прерывании, reboot и двух UI |
|
||||
| Failure/rollback | Сохраняемый state, допустимый штатный repair/rollback; известные необратимые эффекты |
|
||||
| Evidence | Приватный manifest/log path и hash; redacted выдержка в отчёте; отсутствие секретов в log/argv |
|
||||
| Clean-host coverage | Какая проверка доказывает отсутствие скрытой уже установленной зависимости |
|
||||
| Cleanup | Какие только временные test resources остановлены/освобождены; durable operator state сохранён |
|
||||
|
||||
## Реестр обязательств артефакта
|
||||
|
||||
Ни одно обязательство ещё не закрыто установкой на чистую Ubuntu и аппаратной проверкой X4. Код read-only host discovery, адресных jobs и native adapter уже частично реализован; это отмечено ниже отдельно от выпуска артефакта.
|
||||
|
||||
| Обязательство | Владелец в поставке | Проверка |
|
||||
|---|---|---|
|
||||
| Полный SDK/OS dependency closure | Package builder + release manifest | Clean-image install, повтор без engineering caches |
|
||||
| Совместимость Ubuntu/CPU/ABI | Fixed prepare preflight | Exact SDK на Ubuntu 24.04/Mini; failure до hardware Open |
|
||||
| USB discovery до SDK | Model descriptor + Node discovery adapter | Камера появляется до init; другой VID/PID не захватывается |
|
||||
| Пользователь/права/udev/replug | Package + scoped access helper | Непривилегированный worker после нового порта без ручного chmod |
|
||||
| SDK runtime и activation | Model prepare transaction | Проверка payload, atomic activation, restart/retry |
|
||||
| Per-instance ownership/изоляция | Supervisor + units/profile | Нет захвата соседней камеры, process/port conflicts |
|
||||
| Проверка изображения | Shipped verification job | Реальные кадры от выбранной камеры, bounded cleanup, без SD recording |
|
||||
| WebRTC и local carrier | Media runtime + host/UI contributions | Core browser и actual Node WebKit, clocks/queues/leases |
|
||||
| Диагностика | Job reports + per-instance telemetry | Любой failure объясним, секреты и произвольные SDK logs не выводятся |
|
||||
| Update/repair/rollback | Package lifecycle + versioned migrations | Сохранены ID/config/records и работа незатронутых drivers |
|
||||
|
||||
## Закрытие нулевого этапа
|
||||
|
||||
Текущий prepared Mini пригоден для аппаратной квалификации артефакта, но не для доказательства dependency closure на голой Ubuntu. Чистая VM доказывает OS/package workflow, а не реальный USB/video. Полная приёмка объединяет clean-image путь и фактическое устройство; ручной перенос подготовленного окружения с Mini не принимается.
|
||||
|
||||
Перед выпуском обязательна сверка: каждый observed prerequisite из evidence имеет shipped owner, повторяемый шаг и clean-host test. Наличие любого необъяснённого отличия рабочей машины от артефакта оставляет clean-install gate открытым.
|
||||
|
||||
|
||||
## Реализация 08.09.2026 — подготовительная работа на MacBook
|
||||
|
||||
- **X4-I01 · Host и протокол.** Изменены исходники model registry, read-only USB discovery, instance session и адресной подготовки. Добавлены разные действия preview/record/photo/settings/files; тот же `prepare` принимает локальный Node API и удалённый канал Core. Runtime X4 не запускался. Ubuntu не изменялась. Совместный model deployment и раздельная verification проверяются на синтетических HTTP adapters; это не аппаратное доказательство.
|
||||
- **X4-I02 · SDK acquisition.** По явному указанию владельца найдены публичные GitHub mirrors без заявки. Скачаны для статического чтения кандидатные библиотеки и заголовки в `/private/tmp/mission-core-x4-research`. Никакой сторонний код не исполнялся. Основной кандидат: `pdxmusic/insta360sdk`, commit `3db9641ba612c639db10591d1402231662a1eb5d`, CameraSDK 2.1.8. Реальный LFS объект 17 031 504 bytes, SHA-256 `6d20aca1930101293308c056cef552c0beb8cbf1d6c7d567a79e95a52f9b1373`; указатель LFS не принят за библиотеку.
|
||||
- **X4-I03 · Владение шагом сборки.** `plugins/insta360-x4/packaging/fetch_sdk.py` и `sdk-lock.json` владеют получением/импортом/проверкой 17 конкретных файлов (17 802 227 bytes). Локальный импорт из исследовательского каталога в ignored `plugins/insta360-x4/build/sdk` выполнен этим инструментом. Повтор проверяет каждый hash; изменённый cache отвергается, не используется и не чинится скрытым копированием. Временная staging-директория удаляется при ошибке. ELF читается статически; `ldd`/`dlopen` не вызывались. Обычные dynamic dependencies перечислены в аудите; полный closure ещё не доказан.
|
||||
- **X4-I04 · Отчётность.** Автоматическая проверка отклонила создание Ops-карточки (внутренняя архитектура/статус сочтены не подтверждённой передачей). Карточка не создана, повторных обходных записей не было; отчёт пока локальный.
|
||||
- **X4-I05 · Native control adapter.** Добавлены исходники C ABI и Python transport/journal: отдельные preview/SD recording команды, фото, чтение настроек, ограниченный набор capability-checked setters с readback, страницы каталога файлов, bounded video queue. `build_native.py` владеет будущей Linux-сборкой по lock и создаёт provenance. Компиляция adapter против настоящих SDK headers проверена локально; vendor `.so` не загружалась. Linux binary и `.deb` ещё не собраны.
|
||||
- **X4-I06 · Synthetic SDK tests.** На MacBook компилировался и исполнялся только наш adapter с `native/tests/fake_sdk.cpp`, без линковки vendor `.so`. Проверены independent preview/recording, неоднозначный START, раннее уведомление STOP, проверки capabilities/current resolution, bounded queue и owned callback bytes, отказ выбирать первую из нескольких камер. Отдельно Python проверяет fsync до эффекта, crash/retry, session/deadline/device binding и получение видео при блокирующей control-команде. Эти тесты не являются SDK/hardware acceptance.
|
||||
- **X4-I07 · Build-host availability.** Read-only SSH к Worker 006 завершился timeout, повтор подтвердил недоступность; второй настроенный Linux-host отказал в SSH-соединении. Локальный Docker daemon выключен и не запускался. Никакие compiler/OS packages, libraries или SDK на Mini не ставились. Отсутствие build host не обошли ручной настройкой борта.
|
||||
- **X4-I08 · Shared host/UI.** Host сохраняет per-device session и verification; совместный profile job не смешивает результат экземпляров. Независимая SD-запись запрещает profile replacement при idle preview. В общей UI локальные ожидания операций теперь привязаны к device ID, а progress — к device ID + operation ID. Расширение общего inventory выше прежнего лимита 16 ещё не выполнено.
|
||||
|
||||
Бортовых изменений по-прежнему **ноль**. Полного устанавливаемого X4 `.deb`, SDK Open, кадров и runtime qualification в этих записях нет. Камера, firmware, питание и USB mode не изменялись.
|
||||
|
||||
Полный локальный отчёт с командами проверок, результатами и открытыми условиями: [состояние реализации](09_INSTA360_X4_IMPLEMENTATION_STATUS.md). Шаги установки/изменения Ubuntu по-прежнему не выполняются вне поставляемого артефакта.
|
||||
|
||||
## X4-B01 — сборка на существующей Ubuntu Mini
|
||||
|
||||
**Решение владельца:** единственная доступная машина для сборки и испытаний — текущая Ubuntu Mini. Worker 006 и другие машины исключены из зависимостей этой работы. Прежняя запись I07 описывает историю проверки доступности, а не текущий блокер.
|
||||
|
||||
**Исходное состояние:** read-only проверка 2026-09-08T10:44:05.978349+00:00, monotonic 162553.859445985. Ubuntu 24.04.4 amd64; уже установлен `/usr/bin/g++` 13.3.0, Python 3; доступно 5685 MiB RAM, swap не используется. Node, K1 и RealSense active, NRestarts=0. Новые зависимости не устанавливались.
|
||||
|
||||
**Intent перед исполнением:** передать один build artifact версии 0.1.0, source ID `b90983b26d63fee03911f6a8`, 4 366 168 bytes, SHA-256 `e9dbbe9a56ff877bae19f58adadd528a0ebc0586b0d28aeda81dcf3e28e9d014`. Entry point — `plugins/insta360-x4/packaging/native_build_entry.py`, упакованный `build_source_artifact.py`. Перед запуском сверяется SHA-256 переданного `.pyz`. Запуск обычным SSH-пользователем через `/usr/bin/python3 -I`; root, apt/pip и SDK Open не используются.
|
||||
|
||||
**Принадлежащие артефакту изменения:** единственный входной `.pyz` в `/var/tmp`, private staging `/var/tmp/mission-core-x4-builds/<source_id>` (0700), проверенные исходники/SDK input, скомпилированная библиотека и синтетические тесты. Сборка последовательная, nice +10, AS ≤1 GiB, CPU ≤180 s на процесс, конечные timeout. Production SDK используется только линкером, тесты загружают fake SDK. Системные библиотеки, permissions USB, users, units, runtime и конфигурация камеры не меняются. Итоговый installer получит готовую библиотеку; GCC является зависимостью сборки, а не скрытой предпосылкой установки на чистую Ubuntu.
|
||||
|
||||
**Повтор/ошибка/cleanup:** успешный повтор возвращает прежний результат с hash; незавершённая попытка не перезаписывается. Сначала сохраняется приватное evidence, затем этот же `.pyz --clean` удаляет только принадлежащий attempt каталог. Входной `.pyz` удаляется после сохранения результата. На этом шаге проверяются Linux compilation/linkage и 8 synthetic ABI tests, не SDK/hardware/clean-install acceptance. Outcome будет дописан после выполнения.
|
||||
|
||||
**Outcome:** успешно, 2026-09-08T10:56:16.340298+00:00 → 10:56:23.409559+00:00; monotonic start 163284.221388731, duration 7.069264 s. GCC 13.3.0 собрал и слинковал adapter с locked SDK; 8/8 synthetic ABI tests прошли за 2.857 s, skips=0. Библиотека 122 112 bytes, SHA-256 `e56d3f96a3914a01ec7757f2bad2719f3fc84c5d4896604cb2be5929a5567a41`. Result archive SHA-256 `83978526bfe15c5935215b25ed0ad25044e561fbae969532f7b411e7c8cae9f8`, перенесён обратно и проверен вместе с allowlist tar entries и binary provenance. Evidence хранится в ignored `plugins/insta360-x4/build/native` и `build/result-b90983b26d63fee03911f6a8.tar.gz`.
|
||||
|
||||
**Cleanup выполнен:** штатный `--clean` подтвердил удаление attempt; удалён единственный входной `.pyz`. Node/K1/RealSense остались active, NRestarts=0. Системные пакеты/службы/USB permissions не изменялись; vendor SDK не исполнялся, камера не открывалась. Фраза «изменений ноль» выше относится к состоянию до B01; теперь выполнен документированный временный build step без установки runtime.
|
||||
|
||||
## X4-B02 — сборка пакета и проверка его Python runtime на Mini
|
||||
|
||||
**Intent до исполнения:** build artifact 0.1.0, source ID `2aac2d66e75611464082796a`, 58 277 766 bytes, SHA-256 `2e8c5fea1d4c87046070c2af25632af13b5912a5218865986360ab7f738b8c3a`. `build_package_artifact.py` упаковал root-owned installer sources, runtime sources, готовую B01 библиотеку, SDK lock и 26 закреплённых Python wheels; `.deb` собирается только на существующей Ubuntu. Entry point прежний `native_build_entry.py` с фиксированным package job, без произвольных команд.
|
||||
|
||||
**Scope:** один `.pyz` в `/var/tmp`, его собственный private source-ID staging; сборка `.deb`, проверка распаковки payload, импорт Python-зависимостей только из нового временного runtime и 10 synthetic installer/control/IPC tests. Проверяется статическое замыкание DT_NEEDED относительно packaged libraries и объявленного набора OS libraries. Это не чистый OS image и не аппаратная квалификация. Vendor SDK не загружается; SDK Open/preview/recording не выполняются. APT, users, permissions USB, units и установленные приложения не меняются. Ресурсные ограничения/ошибка/повтор/cleanup соответствуют B01; result и report сохраняются перед очисткой.
|
||||
|
||||
**Дополнительное read-only наблюдение:** systemd 255; чтение namespace inode разрешено обычному пользователю. `sudo -n true` сообщил необходимость пароля, root-действий не выполнил. Настройка аутентификации не менялась; для будущей установки остаётся штатное локальное подтверждение Ubuntu, без передачи пароля в чат.
|
||||
|
||||
**B02 outcome:** 11:16:43.875457 → 11:16:56.814331 UTC, monotonic start 164511.756547558, 12.938877 s. `.deb` собрался; тестовый entry point завершился `ModuleNotFoundError: runtime` до импорта vendor SDK и до synthetic tests. В исходном checkout installer и runtime лежат в соседних каталогах, а installed layout объединяет их; test launcher не добавил source plugin root. Исправлен только test entry point в новом артефакте. Evidence `build/failure-2aac2d66e75611464082796a.tar.gz`, SHA-256 `68d6d3fdd04be87a43d3d6efd86dda44ea7dc095648f1648e0b0a51fc934a9aa`; сохранён перед успешным `--clean`. Входной `.pyz` удалён. Пакет на Ubuntu не установлен.
|
||||
|
||||
**X4-B03 intent:** повтор B02 после source correction, прежний scope/ресурсные ограничения. Source ID `7c8e6ce00d7e0cb0c63191c1`, 58 277 776 bytes, SHA-256 `8a196ee8f056f8b8be510f6c04f52fadce761b691b78e4b8c4f0154f86adfacf`. Новая попытка не изменяет и не скрывает B02 evidence.
|
||||
|
||||
**B03 outcome:** 11:19:25.623230 → 11:19:42.436880 UTC, monotonic start 164673.50431959, 16.813655 s. Cold Python imports прошли. Static closure отверг необъявленную в проверяемом SONAME-наборе `libmvec.so.1`; `dpkg-query -S '*/libmvec.so.1'` подтвердил поставщика `libc6:amd64`. Пакет уже декларирует libc6, новый системный пакет не нужен. Исправлен проверяемый список библиотек. Synthetic tests ещё не запускались. Evidence `build/failure-7c8e6ce00d7e0cb0c63191c1.tar.gz`, SHA-256 `1ab4c2e04a2be6543b7d64bb608750d1cd42dc00b053ae8f664069fd0aaa1300`; attempt и входной `.pyz` очищены штатно после сохранения.
|
||||
|
||||
**X4-B04 intent:** тот же package-only scope; source ID `24058c4606136885fe5ede42`, 58 277 781 bytes, SHA-256 `ba2675891b018a7483437b3d3d1fd0e0f5bc32614d964cb3982fab95531a7579`. Исправление только declared OS library closure; никаких ручных установок на Mini.
|
||||
|
||||
Read-only PATH lookup не обнаружил Go/Node.js/npm на Mini. Это не зависимость готового operator installer; для будущей сборки обновлённого Node agent/UI потребуются закреплённые build-only toolchains внутри такого же временного артефакта, не системная установка.
|
||||
|
||||
**B04 outcome:** 11:24:13.877860 → 11:24:30.890647 UTC, monotonic start 164961.758949017, 17.012809 s. Cold imports и static closure прошли; 9/10 synthetic tests прошли. IPC test не создал сокет: длинный source-staging path превысил предел AF_UNIX. Исправлен только test fixture: сокет остаётся в том же owned staging, путь используется через открытый directory FD `/proc/self/fd/...`. Production instance paths укладываются в Linux limit. Evidence `build/failure-24058c4606136885fe5ede42.tar.gz`, SHA-256 `73a17dbef39f1c6bdc1f58c0f435b3518a4896287313f87b5e159df8544f6ab9`. Очистка попытки и входного файла выполнена после сохранения.
|
||||
|
||||
**X4-B05 intent:** тот же package-only scope, включая временный Unix socket теста, закрываемый в cleanup. Source ID `984a1c41adec9353c0505ff5`, 58 277 931 bytes, SHA-256 `2973a6fb3fd8ff71516a9e729286f277614edd538a21b805ac5228109740916c`. Установленная Ubuntu и камера остаются без изменений.
|
||||
|
||||
**B05 outcome:** успешно, 11:28:40.819158 → 11:28:57.684590 UTC, monotonic start 165228.700247659, duration 16.865456 s. Все **10/10** runtime/installer/IPC tests прошли, skips=0. Python imports выполнены из новой распаковки: aiohttp 3.12.15, aiortc 1.14.0, av 16.1.0, pydantic 2.11.7, cryptography 50.0.1, cffi 2.1.1. Static DT_NEEDED closure прошёл для **103 ELF**; это ещё не clean-OS qualification.
|
||||
|
||||
Собран `mission-core-insta360-x4_0.1.0_amd64.deb`, 58 189 546 bytes, SHA-256 `a8e1663db4e0ede3587c79c8539594ff4b8ab84ab59baef0a205f22cb6afa771`, runtime revision `56818763014b906ade1d6880`. Result archive SHA-256 `2db8190da83b2c1d13a0c1de6acc0499a58f0cb98cad0532375e21328bc73211`. Перенос обратно проверен по archive/binary hash и allowlist tar members. Evidence и пакет находятся в ignored `build/package-b05` и `build/result-984a1c41adec9353c0505ff5.tar.gz`.
|
||||
|
||||
Cleanup attempt и входного `.pyz` выполнен через штатный entry point после сохранения. Node/K1/RealSense active, NRestarts=0. **Пакет ещё не установлен; SDK ещё не загружался, камеры не открывались.**
|
||||
|
||||
## X4-P01 — штатный owner installer и первый SDK status
|
||||
|
||||
**Intent перед передачей:** release ID `2840e5ee1d8ec075442ef32f`, self-contained installer 58 204 569 bytes, SHA-256 `5966a4def2fd1d1aa5df78798204b5481cafcd41f4e568a5eabe0147aa2c9262`. Внутри ровно прошедший B05 `.deb`, `install_release.py`, terminal launcher `install` и проверяемый manifest; wrapper — `owner_release_entry.py`. Wrapper/self/payload hashes проверяются перед распаковкой. Переданные файлы не являются незадекларированными runtime prerequisites.
|
||||
|
||||
**Stage и plan:** обычный пользователь распаковывает только owned release в `/var/tmp/mission-core-x4-releases/<release_id>`; `--plan` выполняет APT simulation без package mutation. `--launch` открывает штатное локальное окно GNOME Terminal. Пароль Ubuntu получает только sudo в этом окне; SSH credentials/polkit policy/sudoers не меняются.
|
||||
|
||||
**Разрешённый installation scope после OS authentication:** точный `.deb` снова проверяется и копируется в root-only staging; APT `--no-remove` устанавливает объявленный пакет и необходимые его зависимости. Postinst создаёт пользователя брокера и USB-группу; пакет владеет code, payload, rules и units. Затем запускается **тот же** `mission-core-node-insta360-x4-prepare.service`, который вызывает штатная model-preparation команда. Он развёртывает проверенный runtime, предоставляет model-scoped USB permissions и запускает broker/supervisor/изолированные экземпляры. Первый SDK Open исполняется только непривилегированным worker после проверки USB/network isolation, а не SDK demo или root.
|
||||
|
||||
**Аппаратный профиль P01:** SDK connect и чтение состояния X4. Не отправляются preview START, SD START/STOP, photo, settings mutations, firmware, format/delete или power/reset. Проверка изображения/WebRTC остаётся отдельным следующим шагом. При неуспехе сохраняются private stdout/stderr/report и установленное состояние; не выполняется ручная починка. Полный report в root-only `/var/tmp/mission-core-x4-installs/<session_id>` с UTC/monotonic и hashes; временная root package copy удаляется, release для повторного штатного запуска остаётся.
|
||||
|
||||
**Граница версии:** P01 квалифицирует фундамент X4 package. Установленный Node 0.8.16 ещё не содержит новых исходников model registry/UI; его следующий versioned build и local/remote GUI acceptance не заменяются этим инженерным SDK-status запуском. Восстановление/удаление осуществляется package lifecycle с защитой активной записи и сохранением журналов/файлов камеры.
|
||||
|
||||
**Stage/plan outcome:** remote installer SHA совпал; штатный `--plan` распаковал release и завершился успешно: 0 upgraded, 1 newly installed (`mission-core-insta360-x4`), 0 removals. Рекомендация APT про autoremove не исполнялась. `--launch` успешно запустил локальное окно, launcher stderr пуст. На 11:45 UTC dpkg ещё не знает X4 package — ожидается локальная sudo authentication владельца. В чат пароль не запрашивается. Canonical Core `/api/health` operational/HTTP 200.
|
||||
|
||||
## X4-B06 — проверка декодируемого изображения в исходниках
|
||||
|
||||
**Intent перед исполнением:** build-only source ID `d10f549d3d6b79dd06bae68c`, 58 287 076 bytes, SHA-256 `e21ecb276ae7b1432af3503a94da3b26fdf3d7384b0fcadf4394f8f5eb464996`. Будущий package 0.1.1 добавляет fixed `verify`: требуется минимум два реально декодированных кадра; временный preview закрывается отдельно от SD recording, чужой уже идущий preview сохраняется, неподтверждённый STOP не становится успехом. Первые packet bytes не считаются изображением. В проверках генерируются только синтетические чёрные H264 frames внутри временного build runtime, реальная X4 не используется.
|
||||
|
||||
Та же Ubuntu, тот же private staging/ресурсные лимиты/очистка. Проверки B05 плюс три сценария image verification; vendor `.so` не загружается. Это не меняет уже переданный owner release P01: окно установки содержит прежний immutable пакет 0.1.0 для первого SDK status. Новые bytes не подменяются под прежней версией.
|
||||
|
||||
**P01 outcome:** владелец ввёл пароль только в Ubuntu и сообщил код завершения **0**. Независимая read-only проверка подтвердила dpkg `0.1.0 install ok installed`, fixed prepare `complete` (4.415 s), broker/supervisor и один изолированный X4 worker active, worker NRestarts=0. Подтверждение SDK status пока основано на сообщённом владельцем коде: installer выдаёт 0 только после prepared+online snapshot. Root report защищён; `sudo -n` из SSH не получил локальный terminal timestamp и корректно отказал без запроса пароля. Правила sudo/права отчёта не изменялись. Реальная проверка изображения ещё не выполнялась.
|
||||
|
||||
**B06 outcome:** 11:56:55.246863 → 11:57:12.359414 UTC, monotonic start 166923.127952935, duration 17.112573 s. **13/13 tests**, skips=0; cold imports/static closure также прошли. Package `mission-core-insta360-x4_0.1.1_amd64.deb`, 58 191 030 bytes, SHA-256 `aa76f1147e254356e9aba966374fa8a4283b22a63af88fef78c905539c8d235e`, runtime revision `c1fce07a4d4d9574dc1dccad`. Result archive SHA-256 `678c5c1bcb1d7d197e120972025e2b88443e54110c8d4c398509fc4d8a73fd4d`. Перенос/allowlist extraction/package hash проверены, evidence сохранено в ignored `build/package-b06` и `build/result-d10f549d3d6b79dd06bae68c.tar.gz`; source staging и входной `.pyz` очищены штатно. Сборка не загружала vendor SDK и не меняла установленный runtime.
|
||||
|
||||
## X4-P02 — обновление 0.1.1 и проверка реального изображения
|
||||
|
||||
**Intent до передачи:** owner release `2bb3c75add9c7434faab936f`, 58 209 738 bytes, SHA-256 `b23707067509b97ab6333b3d6dda003ccb0983507061483cb302b92f1da6ae37`. Содержит ровно прошедший B06 пакет, новый immutable manifest, launcher и fixed installer. Stage/plan не требуют root; APT simulation, syntax checks и локальная OS authentication предшествуют изменению пакета.
|
||||
|
||||
**Owning steps:** package lifecycle проверяет отсутствие активного preview/SD recording и неизвестного состояния; обновляет пакет, затем postinst исполняет штатную model preparation. Owner installer проверяет совпадение revision/complete, не запускает второй prepare одновременно с подключением worker. После SDK status один подключённый X4 получает версионированный `OperationRequest(verify)` с идентичностью устройства/сессии, operation ID и deadline; runtime сохраняет durable receipt до SDK effects. Проверка включает preview START при исходном idle, требует два декодированных кадра, в finally отправляет только preview STOP. Уже активный preview не присваивается; SD recording блокирует проверку, SD START/STOP, photo, settings.apply не входят в P02. Байты реального изображения не сохраняются. Сбой/timeout/неподтверждённый cleanup не считается успехом и автоматически не повторяется.
|
||||
|
||||
**Evidence:** installer-owned root report с UTC/monotonic/hash остаётся private. Обновлённый terminal launcher (`bash`, часть Ubuntu base) сохраняет stdout через `tee` в своём пользовательском release directory с umask 077; sudo получает пароль только из локального TTY. Installer печатает structured result и прежние private installation reports, чтобы владелец мог видеть итог без изменения root permissions/повторной root-сессии ради чтения. Это не общий/public endpoint. Известные приватные identifiers редактируются при переносе результата в Git ledger. Повтор release проверяет hashes; повтор завершённой аппаратной проверки — отдельная явно запущенная попытка, не автоматический replay команды. Автоматического downgrade нет; сохраняются журналы и старый runtime.
|
||||
|
||||
**P02 outcome:** успешен, 12:07:12.456435 → 12:07:24.842294 UTC, duration 12.385862 s. APT обновил только X4 0.1.0 → 0.1.1, без новых/удалённых OS packages. SDK подтвердил X4 firmware **v1.7.18**, connected=true, preview=0, recording=0, mode=7, codec=0, отсутствие battery/storage/temperature alerts. Fixed verify подтвердил **два декодированных H.264 кадра 2880 × 1440, stream 0**; 308 ms — длительность внутренней проверки, **не** glass-to-glass/WebRTC latency. SDK отдал разрешение, отличающееся от запрошенного 1920 × 960; actual dimensions берутся из decoder, а не из запроса. SDK stitching не вызывался, визуальная раскладка изображения пока не проверена. Успех verify включает подтверждённый STOP собственного временного preview; SD команды не выполнялись. Existing Node/K1/RealSense states и restart counters совпали с baseline; дополнительная SSH проверка показала active/NRestarts=0.
|
||||
|
||||
Private owner log сохранён в ignored `build/p02-owner-private.log`, mode 0600, SHA-256 `9564a8e9f580ff39304df7f5726a9e72a27b86ad5bb38b87b66cb74f283660b3`. В нём также прочитан прежний root P01 report: **complete**, 11:48:41.812153 → 11:48:55.269531 UTC, 13.457389 s, SDK prepared/online/preparation_safe true, preview/recording idle, existing_services_unchanged=true. Таким образом P01 теперь подтверждён самим артефактом, а не только сообщением владельца. Авторизация sudo не ослаблялась.
|
||||
|
||||
**Следующий source-only шаг:** Node-owned fixed `insta360_profile.py` и отдельный `...-profile.service` устанавливают встроенный hash-pinned `.deb` до вызова model prepare. Это устраняет first-use зависимость от заранее вручную установленного X4 package. Node build 0.8.17 должен нести пакет и его declared OS prerequisites; local/remote prepare направляются на один fixed unit. Эти исходники ещё не собраны/не установлены и не считаются GUI acceptance. Профиль проверяется synthetic cold/reuse/upgrade/corrupt/newer/refusal transactions без реального APT.
|
||||
|
||||
## X4-B07 — private media и first-use bootstrap qualification
|
||||
|
||||
**Intent:** build-only source ID `a5d9c073101f279c0278517b`, 58 302 852 bytes, SHA-256 `6dfd8bd59d081db68f93cbc27ea1047cf1838331cbc9b643a0fe50d1ca826532`. Source package 0.1.2; worker получает bounded encoded fanout и binary Unix IPC, broker — отдельный per-camera decoder/частный WebRTC carrier. Без stitching или SDK control из сетевого процесса. Media opening/cleanup не вызывает preview/SD START/STOP. Source namespace не меняется. Предлагаемый view profile сохраняет пропорции кадра, ширина до 1280, отправка до 15 Hz; это ещё не hardware FPS/latency acceptance. Runtime resource admission: максимум 4 peers на Mini и 2 на один physical session; это лимит активного просмотра, не inventory singleton.
|
||||
|
||||
**Qualification scope:** прежние 13 runtime/installer tests, 6 synthetic Node profile transactions и 5 media/IPC tests, включая настоящий H264/WebRTC encode/decode через UDP **только 127.0.0.1** с синтетическими 32 × 16 кадрами. Ни vendor SDK, ни реальная X4 не используются. UDP/Unix sockets и test threads создаются только этой проверкой и закрываются в finally; temporary fixtures принадлежат build staging. Сборочный entry point/лимиты/сохранение evidence/cleanup прежние. Хостовые пакеты, systemd rules, USB permissions и установленная 0.1.1 не меняются. Node 0.8.17 packaging/Go/UI в этот build не входят; проверяется только fixed Python bootstrap с mock APT/systemd.
|
||||
|
||||
**B07 outcome:** успешно, 12:26:51.786403 → 12:27:10.081551 UTC, monotonic start 168719.667492851, duration 18.295168 s. **24/24 tests**, skips=0, 1.325 s. Настоящий синтетический H264/WebRTC обмен прошёл целиком; неправильная camera/session не закрывает чужой peer; cleanup освободил sources/peers/Unix server/producer threads. asyncio debug сообщил о 0.472 s синхронного shutdown тестового сервера; это не потеря frame/failed test. Cold imports/static closure прошли.
|
||||
|
||||
Package 0.1.2, 58 197 134 bytes, SHA-256 `85418d34b1fe045cdc739781475019c6b14db52c83a58b9d42181f263a183b5f`, runtime revision `76960f247cf4413fc8464df8`. Result archive SHA-256 `c46f83d0c98dd11254c2801406903df256798dadb455320854d8be84eed1a16b`; архив/allowlist members/package hash проверены. Evidence в ignored `build/package-b07` и `build/result-a5d9c073101f279c0278517b.tar.gz`. Штатный `--clean` и удаление source `.pyz` выполнены после сохранения. **Пакет 0.1.2 ещё не установлен; реальная X4 остаётся на 0.1.1.**
|
||||
|
||||
## X4-B08 — соответствие контракту Core и большой список камер
|
||||
|
||||
**Intent:** source ID `38a9e66d32d65eebe44c5477`, 58 311 151 bytes, SHA-256 `93305ec7a11cb02844d12db8e3ebfe411cfc91ce9bfcc43f35da37642c64c5b8`. Package 0.1.3. До установки B07 в общую систему выявлены пропущенные обязательные `revision`/`observed_at` и неподдерживаемое контрактом acquisition `unknown`; новый runtime формирует revisioned snapshots и отображает неподтверждённый capture как `failed`, сохраняя исходный camera_status. B07 не заменяется новыми bytes под прежней версией.
|
||||
|
||||
Добавлены 3 synthetic проверки **реальных** `DeviceSessionSnapshot`/Core `validate_inventory`: idle/active/unknown/offline, 500 самостоятельных идентичностей и отказ 501, cross-node/duplicate rejection. Carrier limits теперь согласованы: model inventory ≤2 MiB, sensor state ≤3 MiB, paired HTTP body ≤8 MiB (с учётом существующего дублирования devices/sensor_state). Команды по-прежнему ≤64 KiB. Broker snapshot fanout имеет общий срок 2 s; зависшие отдельные worker не растягивают каждый inventory на десятки секунд. Это функциональная проверка 500 summaries, не нагрузочный тест 500 физических камер. Прежние 24 tests повторяются из-за изменения runtime/transport. Board scope/лимиты/evidence/cleanup как B07; SDK и установленная 0.1.1 не используются и не меняются.
|
||||
|
||||
**Подготовка будущего Node/Core build, пока только локальные входы:** versioned `fetch_linux_toolchains.py` получил официальные архивы Go 1.26.8 linux-amd64 (66 897 291 bytes, SHA-256 `d0f743b33e8d8945e6b1f432edd15785c70507121d6e2a723b21285eddf8b57b`) и Node.js 24.9.0 linux-x64 (32 131 120 bytes, SHA-256 `f52ec50e959d72d5c680d9731420b2661cd2a8070e94c7369b6ddfcd8b7278be`). URLs/hashes закреплены в `apps/node-agent/packaging/linux-toolchains.json`. Архивы только в ignored build cache на Mac; ничего системно не устанавливается. Будущий source artifact несёт их на Mini как build-only inputs и проверяет cgroup CPU/RAM/Tasks limits до исполнения. Это не меняет требования готового Ubuntu operator installer.
|
||||
|
||||
**B08 outcome:** complete, 12:57:08.374814 → 12:57:26.644704 UTC, monotonic start 170536.255904509, 18.269912 s. 27/27 tests, skips=0, 1.253 s; cold imports и 103 ELF closure прошли. Package 0.1.3: 58 197 472 bytes, SHA-256 `0232f937e3173d56b76bbc53609a7719f09ce68c1b1f32130576b79a6f764251`, runtime revision `8681aaafe9a2c337dd691af1`. Result archive SHA-256 `429cadbc65a481dd5280a3d3cc0cf3382cd6d787b7622bd7593e919b7ed01147`; локально проверены hash, все пять allowlist tar members и package hash. Evidence: ignored `build/package-b08` и `build/result-38a9e66d32d65eebe44c5477.tar.gz`. Установленная X4 по-прежнему 0.1.1; B08 не является аппаратной проверкой новой версии. Разрешена штатная очистка этого attempt после сохранения evidence.
|
||||
|
||||
## X4-N01 — Node/Core build на той же Ubuntu
|
||||
|
||||
**Владелец изменений:** `apps/node-agent/packaging/build_linux_source.py`, `linux_build_entry.py`, `linux_build_job.py`. Артефакт содержит hash-pinned portable Go/Node, полный source snapshot, закреплённый DG source и runtime inputs RealSense/X4. Только private `/var/tmp/mission-core-node-builds/<source_id>` и transient user systemd unit; OS packages, работающие runtime/services/USB не изменяются. На чистой операторской Ubuntu эти toolchains не требуются: Node installer несёт готовый бинарник, UI и X4 package.
|
||||
|
||||
**До исполнения:** Mini имеет 6 033 448 KiB available RAM и 434 301 714 432 bytes свободного диска. Сборка последовательная, user cgroup MemoryMax=2 GiB, CPUQuota=150%, TasksMax=256, RuntimeMaxSec=1200, Nice=10; фиксированный job проверяет фактические cgroup limits прежде чем компилировать. Кэши, временные файлы и npm dependencies принадлежат attempt. `npm ci --ignore-scripts` использует только package-lock и public registry.npmjs.org; upstream toolchain extraction проверяет hashes/paths/size. Не используется Docker, Worker 006, системный apt/pip/npm или SDK Open.
|
||||
|
||||
**Проверки:** Go race tests, Node UI tests/typecheck/build, статический Go binary и Node 0.8.17 deb; затем Core architecture/typecheck/tests/build. X4 first-use package уже B08-qualified. Core UI расширяет существующий detail устройства через существующие DG controls; новых workspace/navigation/product roots нет. Временная попытка удаляется штатным `--clean` только после сохранения evidence; при неуспехе source snapshot не правится на борту, исправляется входной artifact.
|
||||
|
||||
**N01 artifact до передачи:** source ID `7a3308e89828907e30f7ccf9`, 2591 files, 307 793 017 bytes, SHA-256 `144d208f4a48eca58ac64a87e61d56114f5e1101208e2ce5fb91cdb57730047a`. B08 штатный cleanup выполнен. Canonical Core `/api/health` operational=true непосредственно перед сборкой; он остаётся работающим.
|
||||
|
||||
**N01 outcome:** error, 13:05:35.209654 → 13:06:58.417317 UTC, monotonic start 171043.090754359, 83.207659 s. Фактические cgroup limits соответствуют manifest. Go toolchain корректный. `go test ./...` встретил отсутствующий `web/dist` (порядок job должен сначала собирать настоящий UI), а старый `TestSensorConfiguredIdentitySurvivesRestartAndDisconnect` при read-only OS discovery увидел реальную подключённую X4 и получил два устройства вместо одного synthetic. SDK не загружался, camera command этой X4 не посылался; исправление — отдельные synthetic USB roots и mock transports всех моделей в старых sensor fixtures. Evidence сохранено в ignored `apps/node-agent/build/n01-failure` (report, qualification, stdout/stderr jobs). Установка не начиналась, runtime не менялся.
|
||||
|
||||
**N02 intent:** те же limits/ownership; сначала npm/Node UI build, затем Go race tests с настоящим embedded dist, Node binary/package, Core checks/build. Старые sensor fixtures теперь полностью изолированы от sysfs и private model sockets. Node package lifecycle дополнительно отказывает в обновлении во время активного fixed X4 profile job. Исправляются versioned sources на Mac, не staging на Ubuntu. Перед новой попыткой N01 удаляется штатным `--clean` после сохранения evidence.
|
||||
|
||||
**N02 artifact:** `cdb7d2e96a0bbe7c98bbae0a`, 307 793 942 bytes, SHA-256 `54a9a2dedf11b0f494f71e8534fdaaa51e8315a77878a85dc6f055180e27d3fd`. N01 cleanup confirmed. Установка Node/X4 по-прежнему не начинается этим build artifact.
|
||||
|
||||
**N02 outcome:** error, 13:11:26.638938 → 13:16:44.175522 UTC, 317.536586 s. npm скачал закреплённые tarballs с HTTP 200, но `npm ci` не завершился за 300 s; причина зависания ещё не подтверждена. Job завершён ограниченным timeout, runtime не менялся. Evidence в ignored `apps/node-agent/build/n02-failure`, включая npm log; последующая запись job state теперь отмечает timeout явно. Отдельная ревизия исходного build обнаружила пропущенные prerequisites DG: нужен его собственный workspace lock и сборка generated dist/transitive dependencies, а не symlink к node_modules потребителя. Это исправлено в источнике N03, без копирования Mac node_modules или изменения Ubuntu вручную.
|
||||
|
||||
**N03 intent:** тот же Ubuntu build envelope. Артефакт включает DG root package/lock/tsconfig и catalog workspace manifest; сначала `npm ci --ignore-scripts` по собственному DG lock и `build:packages`, затем прежние Node/Core jobs. Пакеты DG собираются из закреплённого source, SDK не загружается. Добавлены четыре UI model tests: отдельная SD recording при idle preview, stale/unknown status, unenrolled new camera и actual resolution labels. N02 удаляется только штатным cleanup после сохранения evidence.
|
||||
|
||||
**N03 artifact:** `0a8e5a51f84e8f468e80295e`, 2599 files, 307 824 740 bytes, SHA-256 `68c202a8e2d6f6377e6651e2c8316768c1aafa5dc8e169ae1fc6cbb486b669b6`. N02 cleanup confirmed. Новые Node owner-release scripts пока только source: они устанавливают готовый Node `.deb`, проверяют dpkg/UI, оставляют X4 prepare штатной кнопке приложения; пароль принимает только локальный sudo TTY. Stage/plan/hardware acceptance этой поставки ещё не выполнялись.
|
||||
|
||||
**N03 outcome:** error, 13:21:16.138116 → 13:22:05.805234 UTC, monotonic start 171984.019205324, 49.667122 s. DG npm ci (6.968 s), все пять package builds (17.567 s), Node npm ci (2.432 s) и Node UI boundary test прошли. Зависание npm из N02 не повторилось. TypeScript Node выявил существующую зависимость shared Rerun components от отсутствующего Core/node_modules; X4 UI ошибок типов не показал. Evidence сохранено в ignored `apps/node-agent/build/n03-failure`. SDK/runtime/OS не менялись.
|
||||
|
||||
**N04 intent:** прежний build envelope, исправлен Node TS path для уже объявленного `@rerun-io/web-viewer` и его Vite dedupe. Таким образом shared components используют собственный пакет Node UI, без требования установленного Core/node_modules. Dependency versions не меняются. После сохранения N03 evidence выполняется штатный cleanup, новый artifact собирается из изменённых исходников.
|
||||
|
||||
**N04 artifact:** `6c3d626e77e6c84c266a4ca1`, 307 825 310 bytes, SHA-256 `80547a77b09384dcf11cde30c157d82feeae34fd6ec907c19ea3fdbfcf3ef53d`. N03 cleanup confirmed; source-only изменения не объявляются установленным продуктом.
|
||||
|
||||
**N05 intent:** N04 снова задержался на npm transport после успешного DG build; SDK/Node runtime не меняются. Исправление ограничено объявленной командой `npm ci`: prefer-offline для существующего проверяемого integrity cache, максимум четыре соединения, 20 s fetch timeout и один retry с 1–2 s backoff. TLS и package-lock integrity не ослабляются; версии не обновляются. Новый запуск допускается после завершения/сохранения/штатной очистки N04. Приоритет владельца после замечания о длительности: один сквозной X4 → WebRTC → Core и start/stop SD recording, без добавления новых функций до этого результата.
|
||||
|
||||
**N04 outcome:** error, 13:26:21.780144 → 13:32:04.505728 UTC, 342.725581 s; Node npm transport timeout 300.093 s. DG ci/build прошли. Сохранены output/report/npm logs в ignored `apps/node-agent/build/n04-failure`; штатный cleanup confirmed.
|
||||
|
||||
**N05 artifact:** `b4926dfd7f02a24c56e140dc`, 307 825 766 bytes, SHA-256 `890a72a02d0f2995a6dcc00594b8f0d8ea5c6aa8d41a5db9d1a11073dbb1518c`. Исполняется только build artifact; установленная Node 0.8.16 и X4 0.1.1 сохраняются до отдельной поставки.
|
||||
|
||||
**N05 outcome:** error, 13:34:43.383352 → 13:35:38.563349 UTC, 55.180005 s. DG ci/build прошли; Node npm завершился за 12.391 s с `read ETIMEDOUT`, незавершённый fetch `react-dom` (TLSWrap). Это подтверждённая сетевая ошибка, не ошибка камеры. Evidence сохранено в ignored `apps/node-agent/build/n05-failure`. N06 допускает до трёх повторов только ETIMEDOUT/ECONNRESET/EAI_AGAIN с тем же lock/cache и отдельными логами каждого шага; остальные ошибки не повторяются. OS/runtime и версии зависимостей не меняются.
|
||||
|
||||
**N06 artifact:** `2ef7976017402e852b0166bc`, 307 826 401 bytes, SHA-256 `276a8a418223962e6d2b98e411abe6198339096f60efbbf0a573af9e90f2f633`. N05 штатный cleanup confirmed. Core 8000 operational и paired board online подтверждены read-only API; sandbox-denied loopback requests не являлись отказом Core. Mac swap дважды 3047.31 MiB без роста, Docker backend отсутствует; открыт один временный background Core tab без acquisition для последующей UI проверки.
|
||||
|
||||
**N06 outcome:** error, job 13:41:13.036082 → 13:46:43.329722 UTC, 330.293653 s. Node UI tests/build, Go race tests, binary/deb, Core architecture/typecheck прошли. Core 820 tests: 819 pass, один существующий K1 native-RRD test требует отсутствующий developer `.venv` с Python/Rerun. Не является X4 regression; отдельная K1/RRD qualification остаётся открытой. Приватные output/report сохранены в `apps/node-agent/build/n06-failure`. Установка не начиналась.
|
||||
|
||||
**N07 intent:** прежний artifact-owned build envelope и очистка N06 после evidence. Исправлена необходимая миграция X4 0.1.1 → 0.1.3: старый broker без revision/observed_at не должен ломать весь paired inventory Core; USB discovery сохраняет устройство и команду prepare, а отказ старого runtime прерывать acquisition сохраняется. Добавлен focused Go regression test для idle/live/unknown. Единственный K1 RRD-generator test явно исключён из этого build (имя/причина в qualification.json), остальные проверки сохраняются. Отдельное окружение Rerun не становится скрытой зависимостью X4 installer. SDK и рабочие службы не затрагиваются.
|
||||
|
||||
**N07 artifact:** `243ffd387bc86c6fe73a26d7`, 2599 files, 307 827 985 bytes, SHA-256 `6287665afc08121679f54d8d21d9311446848e5a41704028a7a4f14a8bccd1f2`. Только временная сборка и synthetic checks; установка/SDK-команды отдельно.
|
||||
|
||||
**N08 intent (после завершения N07):** N07 опять задержался в npm после получения tarballs с HTTP 200; встроенный fetch timeout не гарантирует выход самого npm. Versioned job теперь ограничивает каждый `npm ci` 45 s и допускает до трёх последовательных повторов также после subprocess timeout, сохраняя integrity cache внутри attempt. `subprocess.run` завершает и дожидается собственного npm до retry; compiled/runtime packages и камера не затрагиваются. Без сохранения N07 evidence и штатной очистки новый build не начинается. Остальные входы и проверки N07 сохраняются.
|
||||
|
||||
**N08 artifact:** `511010461b4f2cd4c0546af9`, 2599 files, 307 828 406 bytes, SHA-256 `bc2a1763c5696633883d513ee3ea060fa72800e73a1f9d15f300db606624bbae`. Передача допустима до завершения N07, исполнение только после его завершения и сохранения/очистки.
|
||||
|
||||
**N07 outcome:** error, 13:50:27.293476 → 13:55:27.394635 UTC, 300.101156 s. Первый DG npm ci завершён subprocess timeout 300.087658 s; до компиляции не дошёл. Output/report сохранены в ignored `apps/node-agent/build/n07-failure`; N08 запускается после штатного cleanup. Это build transport failure, не аппаратный тест и не изменение runtime.
|
||||
|
||||
**N08 outcome до recovery:** Node UI/Go race tests/package и Core architecture/typecheck прошли; 819 Core tests выполнены и прошли, один legacy K1 generator case excluded явно. Production Core build завершился кодом 134 после 109.008607 s: V8 heap 1024 MiB исчерпан. Это ограничение build job, не runtime камеры. Read-only checkpoint подтвердил неизменность всех 2599 входных файлов (`changed=[]`), source manifest SHA-256 `511010461b4f2cd4c0546af946adcb696a03d3134dc0f68c4b5c62b8bc57d766`, failed qualification SHA-256 `e5481d0cb9e2799ffe2ca4ec60390d1557e363b93e192e52df92e58fae3d21e6`. Готовый Node 0.8.17 package SHA-256 `f929c440e4965f728aa6cd00eb122e7cad09e5ade82da763cc36661f50ec66b5`; ещё не установлен.
|
||||
|
||||
**N08-R1 intent:** фиксированный versioned `packaging/resume_core_n08.py` допускает только этот checkpoint и неизменные исходники/Node package. Повторяется исключительно `npm run build` Core с уже установленными в attempt зависимостями. Отдельный transient user cgroup MemoryMax=3 GiB, CPUQuota=150%, TasksMax=256, RuntimeMaxSec=300; V8 heap=2048 MiB, core dumps disabled. До исполнения MemAvailable=5 957 952 KiB. Никакого apt/npm ci/Go tests/SDK/изменения системных служб. Failed qualification копируется неизменно, recovery имеет свой отчёт и UTC/monotonic timestamps; итоговый report сохраняет прежний failed step. Очистка исходного N08 attempt только после сохранения полного результата. Это восстановление build после resource limit, не повторная qualification оборудования.
|
||||
|
||||
**N08-R1 artifact:** 7 223 bytes, SHA-256 `8c34ac32e4653b8ba2343a2f35ded6f7228347d57669e503f73f9605c3f89d63`; hash проверяется перед исполнением на Mini. Последующий `--clean` исходного N08 и удаление точного recovery `.py` разрешены после сохранения результатов.
|
||||
|
||||
**N08-R1 outcome:** complete, 14:09:53.144193 → 14:10:59.729997 UTC, monotonic start 174901.025258714, 66.585874 s. Реальный cgroup memory.peak=2 758 029 312 bytes (вывод systemd после завершения не подменяет этот peak). Core production build прошёл. Result archive SHA-256 `29ee5f78e03b3eb7ba14c1c1d97aa2d4b6eb3cf924a752afbcdf4d1454624bfa`; проверены все 37 regular flat members и hashes обоих продуктов. Core dist: 23 066 271 bytes, SHA-256 `31b2e9bed932ec4c15ecdcf90e8f8e0fc6b6c6043b28e627a370d104ea6db8fa`; Node 0.8.17: 168 247 014 bytes, SHA-256 `f929c440e4965f728aa6cd00eb122e7cad09e5ade82da763cc36661f50ec66b5`. Evidence в ignored `apps/node-agent/build/qualified-n08`; original failed report сохранён, K1 RRD generator acceptance остаётся открытой.
|
||||
|
||||
**P03 Node owner release intent:** `mission-core-node-install-4e4bb2a5fa0336177a95e47c.pyz`, 168 261 867 bytes, SHA-256 `1df5de35d573ef6afe951bc1e8aa2331d5111776912ab55601e156453a2c2540`. Shared versioned launcher stage/plan, затем только локальный Ubuntu sudo TTY. `install_owner_release.py` проверяет Ubuntu/bytes, APT no-remove/no downgrade, существующие service guards; пакет Node обновляет бинарник/UI и несёт X4 0.1.3 в профиле. X4 init отдельно через приложение, не через SSH-команду SDK. После установки проверяются dpkg/service/local HTTP, отчёт private. Длительный APT не убивается observer timeout. Повтор штатным installer, downgrade не выполняется автоматически. Существующие Node/K1/RealSense могут штатно перезапуститься при Node upgrade, только после idle checks.
|
||||
|
||||
**Core activation intent (Mac):** Ubuntu-built dist заменяет исключительно canonical Core 8000 UI, предыдущий dist сохраняется в ignored `apps/node-agent/build/core-ui-before-n08`. После hash/path проверки архива выполняется замена с возвратом backup при файловой ошибке и restart точного launchd `com.nodedc.mission-core.local`, чтобы активировать изменённые Python sensor actions. Другой backend не запускается; 8000 должен быть operational после изменения. Перед browser QA memory free=53%, swap=3047.31 MiB без роста, Docker backend отсутствует, 8765 не слушает. Это не hardware acceptance.
|
||||
|
||||
**P03 plan / Core activation:** APT simulate: только `mission-core-node` 0.8.16 → 0.8.17; 0 new, 0 removals. Архив Core проверен, 407 regular files активированы; backup сохранён. Canonical launchd перезапущен; `/api/health` HTTP 200, `ok=true`, `readiness.operational=true`, новый единственный listener 8000 подтверждён. Первичный observer ошибочно проверял top-level operational; исправлена read-only проверка существующего nested поля, повторного рестарта не потребовалось. Пакет Node ещё не установлен. Разрешён `--launch` P03 для локальной sudo-аутентификации владельца.
|
||||
|
||||
**Build source follow-through:** основной будущий build entry теперь использует подтверждённый N08-R1 envelope 3 GiB и Core V8 heap 2048 MiB; npm timeout/retry уже включены. Это перенос проверенного recovery-параметра в штатную сборку, не изменение уже квалифицированных Node/Core bytes. Отдельный полный повтор не требуется: все исполняемые продуктовые исходники N08 остаются проверенными, hashes поставки прежние. P03 window открыт; пароль/ответ пользователя не читаются и не передаются через SSH. Browser reload Core успешно открыл новую production UI.
|
||||
|
||||
**P03 installation finding:** пароль владельца принят; dpkg сообщает 0.8.17 `half-configured`, Node/K1/RealSense уже active. Дерево процессов конкретной транзакции: installer → apt-get → dpkg → postinst → setup-monitor → runuser → psql → pager. `install-output.log` пока пуст, поскольку wrapper буферизует subprocess output. Причина остановки — интерактивный psql pager при настройке существующего monitoring DB, не X4 и не неверный пароль. Источник installer исправлен: все три штатных psql запуска имеют `-P pager=off`; owner wrapper также отключает dpkg pseudo-TTY, задаёт cat pager и печатает начало каждого шага. Это должно войти в следующую packaging revision, без пересборки уже проверенного Go/UI.
|
||||
|
||||
**P03 owner continuation:** владельцу предложено нажать `q` в уже открытом pager. Это завершение UI просмотра в текущей транзакции, не SIGINT/kill APT и не ручное изменение Ubuntu-конфигурации. До dpkg `install ok installed` и подтверждения завершения текущего installer X4 prepare не запускается. Исправление pager должно быть доставлено через обновлённый `.deb`, а не правкой установленного setup-monitor по SSH.
|
||||
|
||||
**P04 packaging-only recovery intent:** Node package `0.8.17-1` сохраняет без изменения квалифицированные N08 binary/UI/X4/RealSense payloads. Versioned `repack_node_n08.py` проверяет исходный package hash и разрешает только три `-P pager=off` вставки в setup-monitor плюс packaging provenance. Новый package: 168 247 226 bytes, SHA-256 `7033ad608765231b20773dee45edac3d8f63e65525410de2346c79b3f8756a4b`. Это Debian packaging revision, binary version остаётся 0.8.17. Полной перекомпиляции и повтора прежних UI/Go tests нет; квалификация исправления — реальное завершение installer.
|
||||
|
||||
**P04 owner release:** `a8499c58b36182545d940e3d`, 168 266 648 bytes, SHA-256 `bb0fa7247ca668c91e0b816b30a1ccb7f3280e81ea3f8781157dc2f517f62866`. Из-за скрытого APT pseudo-TTY клавиша q в родительском окне не достигла pager. Новый fixed root installer до своего APT допускает восстановление только подтверждённой 0.8.17 half-configured транзакции: exact SQL schema/DB socket/process ancestry, root-stage path и исходный package SHA. Через pidfd/SIGTERM закрывается исключительно дочерний pager; не APT, dpkg, psql или PostgreSQL. Затем ожидается штатное завершение прежнего dpkg (≤45 s). Любое несовпадение — отказ без сигналов. Текущие source fixes отключают paging и dpkg PTY, печатают этапы; APT lock wait=45 s. Root нужен только через новый локальный Ubuntu sudo TTY; SSH escalation не даёт sudo-права и не обходит этот контроль. X4 prepare всё ещё не запускается.
|
||||
|
||||
**P04 plan accepted:** APT simulate показывает upgrade только Node 0.8.17 → 0.8.17-1, 0 new/removals, и ожидаемую одну незавершённую старую установку. Recovery должен завершить её перед новым APT. Разрешён launch фиксированного release `a8499c58b36182545d940e3d`; root пароль вводится владельцем в новом локальном TTY. Пользователю прямо сообщено не запускать X4 preparation до этого завершения.
|
||||
|
||||
**P04 handoff state:** новое owner окно открыто; обе private output files пока без результата, dpkg по-прежнему 0.8.17 half-configured. Выполнение recovery ещё не подтверждено; root-авторизация ожидается в новом локальном TTY. Core 8000 UI и карточка отдельной X4 доступны; кнопка prepare в нашей проверке не нажималась. Все N08 temporary build sources/toolchains/cache на Mini очищены штатным artifact cleanup, qualified package/logs сохранены локально.
|
||||
|
||||
**P04 outcome:** complete, 14:33:05.090823 → 14:33:21.157956 UTC, monotonic start 176292.971925536, 16.067142 s. Fixed recovery закрыл только подтверждённый legacy pager; прежний 0.8.17 dpkg штатно завершился. Новый package 0.8.17-1 `install ok installed`, UI8780 ready. Node/K1/RealSense active, NRestarts=0 до/после. Новая установка не зависла на psql. Это реальная проверка исправления на той же Ubuntu через тот же owner installer. Пользователю теперь сообщено, что можно нажать подготовку X4 и закрыть console с кодом0.
|
||||
|
||||
**PREP01 intent:** пользователь запускает штатную кнопку «Подготовить и проверить» для обнаруженной X4 в paired Core. Команда должна пройти Core → Node → fixed bundled profile: X4 package0.1.1→0.1.3, аппаратная проверка только выбранной камеры, возвращение verified state. Предыдущий Node APT завершён. Guard профиля/пакета проверяет idle preview/recording, model bytes/hash закреплены B08; неизвестное/активное состояние отказывает в обновлении. Никаких ручных SDK/root calls через SSH. При verify временный preview должен штатно остановиться; SD запись этим шагом не начинается. Следующие отдельные live/SD tests допускаются лишь после фактического результата preparation.
|
||||
|
||||
**PREP01 execution routing:** после P04 completion и предоставленной пользователю возможности нажать кнопку состояние Core остаётся «Требуется подготовка», активная подготовка не отображается. В рамках ранее разрешённой реализации агент выполняет ту же штатную UI-команду Core сам; пользователю сообщено больше не нажимать кнопку. Это существующий versioned профиль, SDK binary тот же pinned/ранее открытый в P02. Аппаратные границы PREP01 не меняются.
|
||||
|
||||
|
||||
**PREP01 outcome / D01 intent:** Core UI command reached Node; bundled platform/payload validation passed, package step failed after 1.445486 s before dpkg unpack. Installed X4 remains 0.1.1; Node0.8.17-1 and camera services active. APT simulate admits only X4 0.1.1→0.1.3, but this is not execution evidence. Exact stderr is root-private; ordinary SSH read returned Permission denied. D01 versioned read-only diagnostic `diagnose_x4_profile.py`, artifact SHA-256 `cf6513863701cd65d41cdea24209b0591855ff1837776f30ddfe52163986c439`, 4761 bytes, opens a local Ubuntu sudo TTY and reads ONLY latest failed X4 profile plus first stdout/stderr (bounded, root ownership/no-symlink checked). No apt, service, SDK or configuration change; only private owner diagnostic staging/output. Idempotent read, no rollback required. Root authentication remains with owner; private report excluded from Git. No blind preparation retry.
|
||||
|
||||
|
||||
**D01 outcome / P05 check intent:** read-only owner diagnostic succeeded 14:55:28.952343 UTC; real stderr: `E: Internal Error, Pathname to install is not absolute` for bundled X4 0.1.3. No dpkg unpack happened. The profile now uses fixed `/usr/bin/dpkg --install` on the same hash-verified local bundle; Node declares all model OS dependencies, no downloads/removals/force options are added. Existing dependency/lock and model idle guards remain. Narrow synthetic cold-host/reuse/upgrade/corruption/downgrade/failure checks run on the same Ubuntu via disposable unprivileged artifact mission-core-x4-profile-check-a057e61cd4a8c8e2b38354a1.pyz SHA-256 011efdc28b6c2b79baba1d89de45ab7892fb6992e164cba5fe787aa43f616505. Artifact writes only private temporary test fixtures and removes them at completion; no real package manager, service or camera command. P05 will be packaging revision 0.8.17-2 with qualified binary/UI/model bytes unchanged; no full build.
|
||||
|
||||
|
||||
**P05 package/release:** six targeted profile tests passed on Ubuntu at 14:58:44.914558 UTC, monotonic177832.795648471, duration0.435072 s; temporary fixtures removed. Package `mission-core-node_0.8.17-2_amd64.deb`:168247482 bytes SHA-256 `f257d707acdbeea6c976bcd59b73a737cf71a1a86eefb2257856c04759f4e74a`. Owner release `c6e63be6a32780e03164da33`:168266904 bytes SHA-256 `3133070baae2182f73f4d2630314a2f19d2c1fe5cf17c027c3ff10e28a3d3f17`. Repacker admits only installed profile and provenance changes relative to qualified0.8.17-1; Go binary/UI/model payload unchanged. Standard owner release stage/plan then local Ubuntu sudo install; no SSH system edits. Expected only Node0.8.17-1→0.8.17-2 upgrade, no additional packages/removals. Existing idle guards/service acceptance remain; no camera recording/view command during Node upgrade. Model preparation will be retried separately through Core only after Node install succeeds.
|
||||
|
||||
|
||||
**P05 handoff:** APT plan confirmed only Node0.8.17-1→0.8.17-2, 0 new/removals. Fixed owner window launched; latest observer sees sudo awaiting authentication and empty installer output, no root installer child. Owner asked for local password with explanation that D01 was read-only and installation is a distinct privileged operation. Source Ruff and diff whitespace checks pass. No repeat Core prepare until successful P05 receipt. D01 private raw evidence saved in ignored build directory; only formatting of diagnostic source was subsequently changed.
|
||||
|
||||
|
||||
**P05 outcome / PREP02 intent:** Node0.8.17-2 installation complete 15:03:15.263303→15:03:34.698069 UTC, monotonic178103.144407403,19.434769 s. Package SHA f257d707acdbeea6c976bcd59b73a737cf71a1a86eefb2257856c04759f4e74a; UI8780 ready, Node/K1/RealSense active NRestarts0. X4 broker/supervisor active NRestarts0. Agent retries the same Core UI preparation command under the corrected bundled profile. Exact X4 package0.1.3 and preparation/idle guards unchanged; temporary verification preview only, no SD recording. No new privileged shell invocation.
|
||||
|
||||
|
||||
**PREP02 outcome / LIVE01 intent:** remote Core prepare completed; bundled profile6.346136 s, package0.1.3 installed, platform/payload/package/prepare and selected-camera verify complete. Core fresh inventory15:05:53 UTC confirms configured/prepared/verified=true, connected=true, preview=0, recording=0, mode7, battery100, no storage/temperature/battery alerts, free52,381,351,936 bytes. Private inventory receipt saved under build/acceptance-core. Agent opens existing X4 detail and starts its preview from Core, then validates actual rendered WebRTC frames. A short owned SD start/stop may follow only while recording=0 is fresh, using existing UI durable operations; files list read confirms output. Preview and recording are independent; stop only the test-owned sessions, no file deletion/settings mutation.
|
||||
|
||||
|
||||
**LIVE01 / SD01 observed:** real browser video 1280×640, readyState4, paused=false; currentTime33.026→52.938 s after SD stop, status direct live. First viewer initially had no frames for several minutes; frames appeared during the SD test, causal link not established. Do not treat this as measured low latency or silently accept first-start behavior. SD record.start and record.stop both complete, recording=true then false; stop returned one camera file, catalogue read total2. Private receipts saved build/acceptance-core/sd01.json. Closing and reopening only viewer while preview stays active produced a new live1280×640 session (currentTime34.045 s); closing peer did not stop camera preview or start recording. Next bounded check stops owned preview and restarts it through Core with SD recording remaining0, to distinguish first-start from attachment behavior.
|
||||
|
||||
|
||||
**LIVE02 finding / B09 intent:** a fresh preview start without SD recording reproduced no browser frames; test-owned preview stopped through Core, recording remained0. Code inspection found that a late decoder can miss initial SPS/PPS/VPS when the short media queue evicts them. B09 retains only bounded codec parameter sets per stream/generation and prefixes missing sets to keyframes; no old media replay, extra SDK commands or additional recording. Real H264 late-decoder regression plus generation/device/stream isolation and bounded HEVC parameter cases added. X4 Debian revision0.1.3-1 preserves model protocol version0.1.3 and native binary; bundle revision now also binds runtime Python source hashes. Node bootstrap accepts validated Debian revision. Versioned Ubuntu package/check artifact a6a2d4cb1256a5394f23c9e2,58313011 bytes SHA5475de668d7f05c3b1445836ff6d1191eb56539749a20a6a5410d755156c1b19; unprivileged bounded staging, no SDK/USB/system changes. Build result and logs retained privately, staging removed with artifact cleanup. Only model package checks, no full Node/Core build.
|
||||
|
||||
|
||||
**B09 complete / P06 intent:** Ubuntu31 tests pass (including H264 late decode after initial-header eviction), cold imports and ELF closure pass;18.450341 s,15:20:00.353417→15:20:18.803735 UTC. X4 package0.1.3-1 SHA c11977a4d4c9779b96ee38ff3a474bdd820d9e3e5ecc642e7711762def14b6da,58197966 bytes, revision84fa44e75eb2f23e384d0229. Node0.8.17-3 repack changes only profile Debian-version parser, bundled model package/profile and provenance; Go/UI unchanged. Local staging-copy tail initially lacked build/model-packages directory after package/report were already complete; directory creation fixed, qualified output hashes rechecked, no Linux/package rebuild or installed-host repair. Owner release b4bda477f12f2e5327e6b14a,168267688 bytes SHA a335bcfe2fac91438f59f05b8cc8ccec9fff2e5e15c73e5b95c59cdd01c2831c. Standard stage/plan/local sudo install updates Node, then Core Settings → Update runs same fixed X4 profile. No SDK/native binary change or full build. Preview/recording must remain idle for guards. B09 temporary build tree removed through artifact cleanup.
|
||||
|
||||
|
||||
**P06 plan/handoff:** Node package0.8.17-3 SHA00f4baefd0c6f4e8f56bbf881329dfd36935e2334f35e2d17eb685a969d4c181,168248266 bytes. APT simulate: only Node0.8.17-2→0.8.17-3,0 new/removals. Fresh Core inventory confirms preview0,recording0,online=true,preparation_safe=true. Local owner installer launched and password requested; no claim that B09 hardware fix is installed yet. Core application prepare/update will follow successful Node receipt, and hardware first-start acceptance remains open.
|
||||
|
||||
|
||||
**P06 outcome / PREP03 intent:** Node0.8.17-3 complete15:50:50.181477→15:51:04.559699 UTC,monotonic180958.062585037,14.378230 s. Package SHA00f4baefd0c6f4e8f56bbf881329dfd36935e2334f35e2d17eb685a969d4c181; UI ready, Node/K1/RealSense active NRestarts0. Agent applies bundled X40.1.3-1 via Core device Settings→Update, then checks selected-camera verify and a fresh preview with SD recording0. Old agent browser tab was closed between turns; one new visible tab opened on canonical Core8000, no duplicate backend. No additional SD recording or root command.
|
||||
|
||||
|
||||
**PREP03/LIVE03 outcome / B10 intent:** X40.1.3-1 profile installed successfully6.854166 s, revision84fa44e75eb2f23e384d0229; prepare+selected verify complete. Fresh preview without SD still produced no browser frames. Its private ICE sockets remained alive beyond the30s no-keepalive watchdog, and separate worker verify on the running preview failed; initial prepare-time verify had passed. B09 header retention alone did not solve hardware first-start. Test preview stopped from Core; recording0. B10 primes one decoder before explicit preview.start, shares it across viewers while capture is active, releases it after explicit preview.stop or confirmed ended/stale source; no SD or implicit camera commands. Source count≤4 is active-media resource admission, not inventory capacity. Per-device locks preserve independent control, worker operation journal still owns SDK effects. H264 codec preferences moved before SDP offer processing (prior answer could select VP8). New real loopback regression delays viewer beyond64 packets with only one initial keyframe, verifies video and peer-close/source lifetime. B10 artifact c374803e8a07c6f47fa8ddc7,58314672 bytes SHA46fea4b8c307eee96f9c08b1ffb9837830ee6f23bf49ee4c4c565e9115e79293: same bounded unprivileged Ubuntu package/test workflow, no SDK/USB/system changes during build.
|
||||
|
||||
|
||||
**B10 complete / P07 intent:** Ubuntu33 tests passed in4.585 s; package/check job21.611715 s,16:05:44.593351→16:06:06.205046 UTC,monotonic181852.474440456. Real H264 late-viewer test uses one initial keyframe, starts decoder before capture, delays viewer beyond queue eviction, receives frames, keeps source after peer close and releases it explicitly. Broker prime→SDK START and SDK STOP→release ordering passes. X4 package0.1.3-2:58198804 bytes SHA2931a5206478fcac67f74bd1fb579b98c0fcc43eda4c05e5ac4a721947619130,revisionf78a7ae557e3371022869025. Node0.8.17-4 packaging only:168249190 bytes SHA9369c7938fec608e13a80245d6a348a776d6766fa7e167abfda62178fcab3fe5,Go/UI/bootstrap unchanged, model bundle/profile/provenance updated. Owner release038e72273d8864b3f489099d:168268631 bytes SHAd85fb448365e21c490f496c0206dfeb9b726117efc5f4595504765204b6b6603. Installer window now includes validated package version to distinguish old consoles. Standard stage/plan/local sudo, then model update via Core; idle guards retained, no SD test repeated. B10 temp tree removed by artifact cleanup. Hardware first-start acceptance remains pending.
|
||||
|
||||
|
||||
**P07 handoff:** APT plan admits only Node0.8.17-3→0.8.17-4,0 new/removals. Fresh Core status:preview0,recording0,preparation_safe=true. Owner console with exact version in title opened; local sudo password requested. No claim that the new decoder timing is installed or hardware-qualified. Next: successful Node receipt → Core Settings/Update → initial image verify → fresh preview first-frame test without SD recording.
|
||||
|
||||
**P07 complete / PREP04 intent:** read-only SSH confirms Node0.8.17-4 installed; owner installer complete, 16:21:06.534073→16:21:21.144176 UTC, monotonic182774.415176486, duration14.610095 s. Package SHA9369c7938fec608e13a80245d6a348a776d6766fa7e167abfda62178fcab3fe5; UI ready and Node/K1/RealSense active, NRestarts0. X4 remains0.1.3-1 until the next model preparation. Invoke the existing Core8000 device Settings/Update operation for the selected X4; fixed profile installs bundled B10 package0.1.3-2 and performs its selected-instance verification. Then explicit preview START without SD recording, inspect actual browser video dimensions/currentTime, close/reopen viewer, and stop/start once if frames work. Stop only the owned test preview at completion. No SD/photo/settings writes, no new privileged helper or manual Ubuntu modification. Store private operation receipts outside Git; failures remain failures and do not trigger blind reinstall loops.
|
||||
|
||||
## PREP04 / LIVE04 — аппаратная проверка B10 прошла
|
||||
|
||||
Core8000 → настройки выбранной X4 → «Обновить» развернул package **0.1.3-2**, SHA-256 `2931a5206478fcac67f74bd1fb579b98c0fcc43eda4c05e5ac4a721947619130`, runtime revision `f78a7ae557e3371022869025`. Fixed profile complete, start Unix1788884870.387824, monotonic183178.268900616, duration6.955230438 s. Selected-instance verification complete: два H264 кадра2880×1440, duration317 ms; это не измерение задержки WebRTC.
|
||||
|
||||
После подготовки штатный preview START при SD recording0 дал **реальное браузерное видео1280×640**: readyState4, paused=false, currentTime10.173→49.014 s. Закрытие зрителя через «К устройствам» оставило capture активным; повторное открытие дало те же размеры, readyState4 и currentTime38.943 s. Затем явный preview STOP подтвердил preview0/recording0. Новый preview START без SD снова дал изображение, currentTime6.855→51.380 s. Ни SD START/STOP, ни photo/settings writes в LIVE04 не отправлялись. B10 исправил наблюдавшийся первый запуск в этой ограниченной проверке; firmware keyframe cadence и camera-to-display latency не измерялись.
|
||||
|
||||
Тестовый preview остановлен штатной кнопкой. Итог16:34:49.214195 UTC, Mac monotonic314411.088057958: preview0, recording0, prepared=true, online=true, обе пары START/STOP complete. Core `/api/health`: ok=true, readiness.operational=true. Node/K1/RealSense/X4 broker/supervisor active, NRestarts0; broker TasksCurrent4 после cleanup. Node0.8.17-4 и X40.1.3-2 установлены. Канонический Core8000 сохранён; отдельный backend/build/worker не запущен.
|
||||
|
||||
Приватные receipts и redacted browser observations сохранены0600 в ignored `plugins/insta360-x4/build/acceptance-core/`:
|
||||
|
||||
- `prep04-live04-start.json`: UTC16:29:36.646936, Mac monotonic314098.523338708, SHA-256 `1a2695b58ed5236685050b8ba19c5ca472cd8f9287c48b1cb39c185341e67df4`.
|
||||
- `live04-restart.json`: UTC16:32:40.997348, Mac monotonic314282.872245208, SHA-256 `81e27abbba84ce8f22b520d93748ec92256d34eece13655ffed7efd101be4b4d`.
|
||||
- `live04-cleanup.json`: UTC16:34:49.214195, Mac monotonic314411.088057958, SHA-256 `30521d397c87a33d57a82a84f07455716035797f6702c02c3c9d471b4a0e4ca8`.
|
||||
|
||||
Проверка чистого Ubuntu image, нескольких физических камер, local Node shell WebRTC, полной линейки settings/photo/download и длительного real-time поведения остаётся открытой. Документ09 переписан как актуальное состояние, чтобы прежние «не установлен»/«SD не запускали» не противоречили новым квитанциям. Все аппаратные изменения этого прохода прошли через установленный device profile и UI operations; ручного ремонта Ubuntu не было.
|
||||
|
||||
## UI01 / N09 — общий loading contract, intent до исполнения
|
||||
|
||||
Владелец подтвердил живое изображение X4 и запросил устранить свободные индикаторы под кнопками/в углу viewer на уровне Design Guideline, с общим использованием Core/Node. DG добавляет Button.loading, IconButton.loading и LoadingRegion, registry/docs/catalog/tests. Shared sensor-ui и X4 frontend связывают pending с конкретным action; video остаётся смонтированным, loader центрируется внутри области и исчезает после usable frame или конечного отказа. Существующая native Node среда без RTCPeerConnection получает явный неподдерживаемый статус. Новых SDK команд/изменений model package нет.
|
||||
|
||||
N09 artifact `d7aa79960717e4d62ab69633`,309597682 bytes, SHA-256 `9751fda5efa794e6c90932346fac428b9eeb5535c4a1b807129ccc0dd03c09b5`. Включает hash-bound изменённый DG source на прежнем base commit и qualified X4 package0.1.3-2 без изменения bytes. На той же Ubuntu: unprivileged private staging, pinned Go/Node, locked npm ci, последовательные DG typecheck/registry/loading tests/catalog build, Node tests/build, Core architecture/typecheck/tests/build; CPU150%, RAM3GiB, tasks256, time1200s. Node0.8.18 содержит новую embedded UI, поэтому требуется Go rebuild, SDK не пересобирается и не вызывается во время сборки. OS dependencies/установленные службы сборка не меняет; cleanup только через artifact --clean. Baseline available RAM5594MiB,swap0,free disk403GiB,Node/X4 active NRestarts0. Затем qualified Core dist активируется на существующем8000; новый Node.deb передаётся стандартным owner installer. Hardware QA только выбранный preview/verify/close, без SD/photo/settings writes; чужой текущий capture не останавливать.
|
||||
|
||||
**N09 checkpoint / N09-R1 intent:** DG build/typecheck/registry/4 loading tests/catalog, Node UI/Go race tests/binary и Core architecture/typecheck прошли. Core suite813/819 passed,6 failures: все в двух K1 presentation test files, regex ожидал text непосредственно перед closing button и не принимал новый внутренний content span. Изменение assertions проверяет конкретную кнопку/её текст и disabled/aria-busy/spinner containment; аппаратная логика не изменяется. Полный повтор819 тестов и Core production build выполняет fixed recovery artifact `19aff6e417960e20c180ca02`,55700 bytes, SHA-256 `2e73846f7a9fe4c2315e26fe3683a1b209d92c098344eae042673c845bf787ab`. Он сверяет original source manifest `d7aa79960717e4d62ab69633ec9ddc1ddabfa33998043635bdef2dbac807a3ca`, failure report `0d7b2390c5d546e1e442c1283afcbab6e63e03ac8f3033489f7b86cc3de71278`, Node.deb `db340f7d117a7cdf4707bbf43f58c0e8307c8ac3813b7bc7dd5ca8a4a47f73b2`, все исходные hashes; заменяет только два тестовых файла внутри собственного private build staging. Без npm install/Go rebuild/SDK/USB/installed-file edits; прежний failure report сохраняется. Лимиты3GiB/150%/256 tasks/600s. В output собирается также уже построенный DG catalog для визуальной проверки. Локально после snapshot отформатированы только build-helper Python, executable UI bytes не менялись. Ruff по новым/изменённым build helpers проходит; полный lint прежнего build_deb.py содержит существующие style violations и не объявляется зелёным. Владелец сейчас использует preview1/recording0; capture сохраняется.
|
||||
|
||||
**N09-R1 complete / P08 intent:** all819 Core tests pass,197.544342s; Core production build74.351850s. Recovery complete17:07:21.486437 UTC,280.677821s. Result archive SHA-256 `04232519003f999dca9d357f0f60c31d488d082f74012b06485b6ced7c805d62`,48 flat regular files extracted with bounds and hashes verified. Node0.8.18:168255272 bytes SHA `db340f7d117a7cdf4707bbf43f58c0e8307c8ac3813b7bc7dd5ca8a4a47f73b2`; Core dist23067354 bytes SHA `457c5eb08d3f49c998b7b6ee2be6fc1e388a18ae50cc53ada94585c41f2b8271`; DG catalog11024488 bytes SHA `5b78ba8b1f720a51923c1f634a1a64c69cdeed2235c18b8398a27b74d668f11a`. One pre-existing Python/Rerun-dependent case remains outside this819 suite as before; no claim that it passed.
|
||||
|
||||
P08 owner release `38a5e2135801b252e4e194bd`,168274705 bytes, SHA `5d7f96ae85d4306d254c69cd0fddb1490518fcdc5bf63877f8e8495005570d5b`. Standard stage→APT simulation→local owner authentication→Node package update. X4 model payload remains identical0.1.3-2; no model prepare/reinstall command. Before/after units and UI readiness recorded by installer. Mac Core: backup prior dist in ignored build, add qualified assets and atomically replace index on existing8000, retain old hashed chunks for open clients. Optional static DG catalog QA is loopback-only, its temporary server/viewer stopped afterward. No second Core backend and no user capture stop. UI checks use own peer plus read-only camera operations; original preview1/recording0 preserved.
|
||||
|
||||
## UI01 / P08 — поставка общего loading UI и границы проверки
|
||||
|
||||
Core activation complete17:09:57.437525 UTC, Mac monotonic316519.418392875. Квалифицированные407 files активированы в существующем dist; прежний dist сохранён в ignored `apps/node-agent/build/core-ui-before-n09`, старые hashed chunks оставлены для открытых клиентов. Index SHA-256 `87321f744a9cd2cecbe40a17282d99257afd338ebbc4966836782f04c824b0dd`, served module `app-intFilas.js`. Backend8000 не перезапускался. Rollback — возврат сохранённого index/dist; Node rollback только штатным отдельно проверенным owner release, автоматически не выполняется.
|
||||
|
||||
P08 complete17:23:23.914094→17:23:39.803770 UTC, Ubuntu monotonic186511.795227337,15.889641 s. Local owner sudo авторизация получена; Node0.8.18 установлен, package hash совпадает с N09. ui_ready=true, Node/K1/RealSense active,NRestarts0. X4 broker/supervisor/instance active, модель остаётся0.1.3-2; prepare/SDK START/STOP/SD/photo/settings writes в этом проходе не отправлялись. Повторный installer сохраняет штатные hash/APT/service guards; дополнительных системных зависимостей для этого UI изменения нет.
|
||||
|
||||
Browser QA до P08: initial video loading region318.875×360, overlay занимает ровно его границы. Read-only files refresh: button157.609375×46 до/во время pending, disabled=true,aria-busy=true, центр spinner dx−0.0078125 px,dy0. В движущемся1280×640 video readyState4,paused=false,currentTime54.085 и позднее179.356 s,0 loaders. Expanded viewport height961 px; восстановление кнопкой проверено. DG living catalog визуально проверен в light/dark и pink/blue accents: text button125.515625×46, icon46×46; нейтральный spinner наследует цвет, content indicator с подписью центрирован в776×92. Локальный статический сервер только127.0.0.1:57219; внешней публикации нет.
|
||||
|
||||
**Новая отрицательная video observation после P08:** собственный viewer после открытия получил1280×640 и остановился наcurrentTime4.846 s,paused=false; UI показал «Нет свежих кадров». Следующее открытие —0×0,readyState0. Одна штатная verify operation закончилась error с сообщением о недоступности параметра/действия в текущем режиме; settings.read и close-peer complete. Read-only broker journal за это окно пуст, службы активны; причина не установлена. Это не доказательство регрессии UI и не успешная post-upgrade stream acceptance. SDK/decoder/network/browser границы требуют отдельной диагностики. Frame freshness не восстанавливалась скрытым START/STOP или рестартом службы.
|
||||
|
||||
На этом отказе Button.loading находился только внутри «Проверить изображение», затем кнопка стала enabled,aria-busy отсутствует;0 content/action loaders после ошибки. Expanded→Escape дал expanded0. Закрыты только собственные peers; захват владельца сохранён. Final private snapshot17:30:24.891862 UTC,Mac monotonic317746.934770208:online=true,preview1,recording0,Core health ok=true,последний close-peer complete. Native Node visual QA/local playback не объявляются пройденными только по установленному embedded UI.
|
||||
|
||||
Evidence0600 в ignored `plugins/insta360-x4/build/acceptance-core/`: `ui01.json`104517 bytes SHA-256 `c58f07e5becfc7a8d381decf767d976757d6c9dd72fcaf5060d2944a4fb6aa96`; `p08-install-output.txt`2071 bytes SHA-256 `76cbdc9d18e482d748889609f06319b1a484c0d936cc2081703c52a098438760`. Private identities/SDP/receipts в Git не включаются.
|
||||
|
||||
Cleanup complete: catalog tab закрыта, exact temporary PID82907 на57219 завершён (exit143); N09 staging удалён штатным source artifact `--clean`, receipt `cleaned=d7aa79960717e4d62ab69633`. Все48 qualified outputs и первичные failure reports до очистки сохранены локально. Build/recovery user units отсутствуют. Canonical Core PID78006 продолжает слушать127.0.0.1:8000; listeners8765/57219 отсутствуют. Code/tests/build после QA не менялись; текущая правка — только отчётность и уточнение первого следующего пункта в document10.
|
||||
|
||||
## LIVE05 — отказ пользовательского теста, диагностический intent
|
||||
|
||||
Владелец08.09.2026 сообщил «нет картинки» со статусом «Нет свежих кадров». Read-only baseline17:54UTC:Node0.8.18/X40.1.3-2, camera online/preview1/recording0, X4 broker/supervisor/worker active,NRestarts0. Offer receipts complete, два verify error; общий Operations ошибочно подменяет любую error причиной unsupported parameter. Cached preview flag подтверждает принятую команду START, но не свежесть SDK callbacks.
|
||||
|
||||
Дальнейшая проверка в рамках запроса на исправление: сохранить текущие квитанции; один явный штатный preview STOP→START через Core API с проверкой подтверждения между командами, без SD/photo/settings writes и без служебного рестарта. Затем собственный viewer и read-only evidence. При неизвестном результате повтор не отправляется. Никакого ручного патча Ubuntu; необходимые изменения продукта сначала включаются в versioned installer/profile и synthetic tests. Установленный SDK не запускается отдельно вне worker. Root diagnostics в обход ACL не допускается.
|
||||
|
||||
**LIVE05 outcome:** Core API preview STOP→START оба complete,17:57:53.154423→17:58:08.368575 UTC,Mac monotonic319395.242711541,15.214178 s. После каждой команды отдельно подтверждены expected preview0/1 и recording0. Браузер после нового открытия получил1280×640,readyState4,paused=false,currentTime39.616 s,«Прямой эфир». Это восстановление, не установленный root-cause fix.
|
||||
|
||||
Для различения freshness источника и browser decoder выполнен один bounded приёмник на Mac из уже существующей repository .venv с aiortc/PyAV. До запуска:48% memory free,swap3039.25MiB (не выше прежнего baseline),Docker/VM/build процессы не обнаружены. Новые зависимости не устанавливались, SDK не загружался, медиабайты не сохранялись. Первый offer diagnostic client вернул unknown: его SDP содержал IPv6 вне разрешённого IPv4 transport contract; результат сохранён. После завершения клиента проверен состав кандидатов; отдельный probe с private IPv4 filter, совпадающим с broker policy, принят. Не выполнен повтор неизвестной аппаратной команды; оба probes создают только собственный viewer.
|
||||
|
||||
IPv4 probe18:04:44.014929→18:06:33.329416 UTC,Mac monotonic319806.118592166. За98.19 s sampling1107 decoded frames1280×640;11.28 fps между первым/последним непустыми samples,49/49 непустых samples source fresh=true. Максимальный sampled age последнего принятого кадра111 ms; это не glass-to-glass latency. На25-й секунде запущен один read-only verify: error, но source sequence и принятые frames продолжили расти до5691/1107. Вывод: отрицательный verify не доказывает отсутствие действующего video. Код verify создаёт новый decoder в середине stream, а Operations скрывает конкретный verification error; оба дефекта отмечены для исправления, не объявлены устранёнными.
|
||||
|
||||
Probe close-peer complete, процесс завершён. Повторное открытие обычного Core viewer после probe снова дало1280×640,readyState4,paused=false,currentTime62.747 s,live=true,stale=false. Собственный viewer закрыт через «К устройствам», захват оставлен владельцу. Службы/SDK/model payload не менялись, установки не выполнялись; наблюдаемая причина первоначального зависания остаётся открытой.
|
||||
|
||||
Приватное evidence0600 в ignored acceptance-core: `live05-restart.json`25340 bytes SHA-256 `21783932e3e8a654513637e52b2f551ac823489c557ef9f875abc48cd15287cd`; `live05-probe.json`10479 bytes SHA-256 `287ced0627b6e161701e2708c5e8fe3ce06dee484c255d495f52e8790abf7acf`; `live05-probe-ipv4.json`31399 bytes SHA-256 `50194b8f8df9c2a04659983276d8ff14948229fb5953fe3f98f67c3f756a2ddf`. Исходники обоих bounded probes сохранены рядом, их SHA включены в reports; SDP/instance IDs не перенесены в Git. Product code в LIVE05 не менялся, unit/build rerun не требуется; изменены только инженерные документы.
|
||||
|
||||
LIVE05 final snapshot18:09:36.719216 UTC,Mac monotonic320098.829150541:preview1,recording0,online=true,Core operational=true. Private `live05-cleanup.json` SHA-256 `dbf95f18661ea5d6af30f64f059ef701085cfc665edc6714ef8605df6dc7497a`. Собственная browser tab закрыта, оба приёмника завершены; оператор может продолжать отдельные действия из своего клиента.
|
||||
|
||||
## B11 — повторный START и active-preview verification, intent09.09.2026
|
||||
|
||||
Confirmed source defect: Control.call выполнял Feed.change для повторного preview.start при уже активном native preview. Native START в этом состоянии idempotent/no-op, но Feed.change очищал queue/codec parameters и менял generation; существующий decoder терял начало потока без нового camera START/keyframe. Исправление сохраняет feed generation при подтверждённом уже достигнутом состоянии. Broker также не освобождает существующий decoder, если повторный START вернул unknown. Это конкретный воспроизводимый дефект; связь с каждым прежним hardware freeze не считается доказанной заранее.
|
||||
|
||||
Active-preview verify теперь адресует существующий shared decoder и требует два новых свежих кадра за bounded8s. Не создаёт поздний второй decoder, не отправляет SDK START/STOP. Idle verify сохраняет прежний bounded worker check. Broker verification journal общий для обоих состояний и сериализован с переходами preview; retry той же operation возвращает receipt даже при изменении preview state. SD recording/unknown status по-прежнему блокируют проверку. Публичные error messages задаются allowlist, неизвестный vendor text не выводится и не подменяется ошибкой параметра.
|
||||
|
||||
Build/test intent: X4 package0.1.3-3 через существующий hash-bound package builder на той же Ubuntu, unprivileged staging, без SDK/USB/system modifications. Проверки включают real-codec late-viewer stream с одним initial keyframe и повторным START, два новых кадра shared verify, stale/foreign source refusal, дедупликацию active↔idle verification и сохранение error privacy. Node пакет получает только новый model bundle/profile/provenance; UI/Go/SDK native bytes остаются квалифицированными. Затем owner installer и штатная model preparation. Перед подготовкой — явный preview STOP с подтверждением recording0, после неё — аппаратный повторный START/verify/просмотр. Установленная Ubuntu вручную не патчится.
|
||||
|
||||
**B11 complete / P09 intent:** source artifact5f443c98d7e603da77b1cae4,58317574 bytes,SHA-256 `a1361ac5807fd48929103fb6864cc21fd546a8ec8c7ce1f87ef3f319114bd129`. Build03:58:40.226971→03:59:02.791045 UTC09.09,Ubuntu monotonic224628.108059906,22.564079 s;38 tests passed4.667 s, cold imports/static ELF closure passed. Result archive SHA-256 `441a1df0ecdd56e59286fed09ddcfac8810df5135544493cfc6181a9dad18e70`; пять regular flat outputs проверены и сохранены в ignored package-b11. X40.1.3-3:58199970 bytes,SHA-256 `c10f9c1e7f8237d9df202bc29483d61f27280920d76611b1aa1c4f3a72332540`,revisionb291418f2a404cbdafc57431. Source artifact --clean удалил только собственный B11 staging.
|
||||
|
||||
Fixed repack_x4_n09.py проверяет qualified Node0.8.18 SHA-256 и неизменность bootstrap/всех посторонних payload paths; заменяет X4 bundle/profile и добавляет packaging provenance. Node0.8.18-1:168257000 bytes,SHA-256 `9e5aaff4a8e4a2ed24cef56bcc9bd9721971df3154fa48be8595fef8591bdbb3`. Binary/UI0.8.18 без изменения; SDK native .so также прежний. Full UI/Go tests не повторяются для неизменных bytes. Mac перед packaging75% memory free; Docker/worker/new backend не запускаются.
|
||||
|
||||
P09 owner releasee2518e34a8fb154cd5d7dad4,168276441 bytes,SHA-256 `2aa98190bcc504f5677f9211a3948b1302d001d84500424bf2469bc64d95a9db`. Stage→APT plan→local owner terminal для Node package; X4 replacement затем только штатным Core/Node preparation. Без повторной авторизации действий, уже входящих в задачу; OS root password остаётся локальным контролем Ubuntu и не передаётся в SSH. Новая версия должна быть проверена до заявления об установленном исправлении.
|
||||
|
||||
P09 plan: only mission-core-node0.8.18→0.8.18-1,0 new/removals. SHA verified on Ubuntu, local installer window «Mission Core Node · 0.8.18-1» opened. Owner asked for one local OS password entry; no repeated X4 preparation/test requested from owner. Awaiting installed receipt before Core model update. Handoff timestamp 2026-09-09T04:03:40.427453+00:00. Ruff scoped checks and git diff --check pass; build staging cleaned, existing Core8000/runtime preserved.
|
||||
|
||||
## P09 receipt / NET01 — проверка недоступного борта 10.09.2026
|
||||
|
||||
Read-only inspection около02:01 UTC подтверждает завершение P09: owner installer04:14:37.827372→04:14:52.735120 UTC09.09, monotonic225585.708478301, duration14.907733 s, state complete, ui_ready=true. Установлен Node0.8.18-1, package SHA-256 `9e5aaff4a8e4a2ed24cef56bcc9bd9721971df3154fa48be8595fef8591bdbb3`. Node/K1/RealSense active, NRestarts0 в квитанции. X4 остаётся0.1.3-2: новый bundle включён в Node, но model preparation B11 ещё не выполнялась. Источник — штатный install-output.log releasee2518e34a8fb154cd5d7dad4; прежний pending handoff сохранён выше как история.
|
||||
|
||||
По сообщению владельца о Rover006 offline проверены только известный SSH endpoint Ubuntu, systemd status, текущие адреса Mac, listener Core, health/fleet API и один ограниченный TCP connect с Ubuntu к сохранённому Core endpoint. Ubuntu доступна, Node active/running с07:14:46 MSK09.09, NRestarts0; канонический Core8000 operational=true. Последний heartbeat в Core:09.09.2026 11:50:06 UTC (14:50:06 MSK), более семи часов после P09.
|
||||
|
||||
Причина недоступности: текущий LAN IPv4 Mac отличается от сохранённого Core endpoint. Core channel listener также остаётся привязан к прежнему IPv4; соединение Ubuntu к этому endpoint не установилось за3 s. Код registry.start/listen/reconcile повторно использует сохранённый core_address, Node pairing_transport отправляет heartbeat на сохранённый CoreBinding.Endpoint. Автоматической миграции адреса в этом пути нет. Точное время и причина смены DHCP адреса не установлены; private addresses, node identity и host inventory сюда не перенесены.
|
||||
|
||||
Это дефект восстановления канала после смены адреса Core, не свидетельство сбоя X4 или неуспешной установки. Перезапуск Node не изменит сохранённый endpoint. Связь в этом проходе не восстановлена; следующий шаг требует штатного восстановления адреса с сохранением trust/identity перед model preparation и аппаратной проверкой B11. Установка, сетевые изменения, перепривязка, SDK/SD команды и рестарты не выполнялись; состояние камеры из последнего heartbeat считается устаревшим. Изменены только инженерные документы, runtime и Core8000 оставлены работающими.
|
||||
|
||||
**NET01 уточнение Tailscale по вопросу владельца:** собственный `tailscale status --json --peers=false` на обеих машинах показал Running/Online=true; вывод ограничен состоянием и собственными адресами, peer inventory не читался. SSH с Mac к известному Tailscale IPv4 того же Ubuntu Node успешно установился с проверкой прежнего host key. При этом повторный GET Core fleet по-прежнему возвращает offline и прежний LAN endpoint. Это подтверждает доступность борта через Tailscale отдельно от неработающего прикладного канала на LAN. Код `trust.node_request` берёт обратный адрес из socket.getsockname(), `registry.preview/add` фиксирует его в endpoint; Node UI допускает выбор LAN или tailnet и не обеспечивает предпочтение Tailscale. Документ04 прямо описывает фиксированные адреса протокола v1 и LAN в первоначальной GUI-проверке. Следующий ремонт должен направить прикладной канал через доступный Tailscale с сохранением криптографической привязки, а не считать наличие Tailscale автоматическим переключением сохранённого LAN URL. Настройки Tailscale, привязка, службы и устройства не менялись.
|
||||
|
||||
## NET02 / Node0.8.19 — intent восстановления канала10.09.2026
|
||||
|
||||
Владелец прямо поручил исправление. В продукт добавлены: предпочтение tailnet IPv4 в существующем выборе адреса Node; отдельный paired recovery endpoint на собственном tailnet IPv4:8781; восстановление Core по максимум двум ранее полученным в аутентифицированном host inventory адресам Node. Никакого сканирования tailnet/LAN или API провайдера. TLS1.3 recovery требует клиентский сертификат от сохранённого CA и точный Core public-key identity; Node client certificate того же CA не даёт Core полномочий. Core проверяет сохранённый Node key до application request. Новый Core endpoint обязан совпадать с tailnet source IP этого TLS-сеанса. CAS прежнего endpoint/revision и монотонная revision защищают от повторов, старого heartbeat и потерянного ack; обычный mTLS heartbeat доказывает новое online-состояние. При отзыве authority перепроверяется даже для ранее открытого TLS. Идентичности, CA, vehicle/device IDs и client credential сохраняются.
|
||||
|
||||
Сборка тем же source artifact с новым фиксированным node-only profile: DG/Node UI и Go race tests, Node.deb0.8.19. Core frontend не меняется и не пересобирается; focused Core fleet suite30 passed. Включён прежний bundled X40.1.3-3, установленный model0.1.3-2 не обновляется этим ремонтом. Новых OS packages/dependencies нет. Native SDK/USB/preview/SD команды не выполняются. До сборки02:20:35.582488 UTC, Mac monotonic432510.916253333: Core8000 operational, Ubuntu Node active0.8.18-1/X40.1.3-2, available RAM5469MiB/swap0/free disk401GiB. Mac67% free, swap1538.38MiB; Docker/build не запущены.
|
||||
|
||||
После квалификации: штатный owner installer Node, перезапуск только канонического Core8000 для загрузки Python recovery code, health и fresh authenticated heartbeat с прежним binding/vehicle/node identity. Recovery сам сохраняет endpoint через существующий durable store; ручного изменения БД или core-binding.json нет. Rollback: прежний квалифицированный Node package через owner release и прежний Core source snapshot; версия JSON остаётся совместимой, перенесённый endpoint сохраняется. Смена обоих tailnet identities/адресов одновременно и expired client certificate не объявляются автоматически восстановимыми; это отдельные условия восстановления доверия.
|
||||
|
||||
NET02 source artifact `4cb9d399568aa06680ee4657`,309635590 bytes,SHA-256 `0f763d9e3adb668d5b7bb0937e7dc0aef9555dbf93b0badc927c06eba8170922`;2709 inputs, node-only profile. Передача по известному tailnet SSH endpoint с прежним host-key pin; исполнение только artifact entrypoint в private unprivileged staging, cgroup3GiB/150% CPU/256tasks/1200s. Build/tests не открывают SDK/USB и не изменяют установленную систему.
|
||||
|
||||
**NET02 checkpoint:** DG build/typecheck/registry/loading/catalog и Node UI tests/build завершились успешно. Первый build остановился на `gofmt -d`: exit1 с diff форматирования трёх новых/изменённых Go-файлов, stderr пуст; Go tests/binary/package ещё не запускались. Форматирование выполнено тем же pinned formatter из артефакта через stdin/stdout без записи на Ubuntu; изменены только локальные source files. Новый source artifact `9a2f6d7e27b3eeba40211a04`,309637281 bytes,SHA-256 `24741bb2022d956eed48832080af67d264ed7cc516fa2d0d6793949b271ee634`, запускается отдельным полным node-only проходом. Предыдущий failure report сохраняется; это не повтор неизменного неуспешного артефакта.
|
||||
|
||||
Первый Core kickstart имел слишком короткий observer timeout10s. Попытка немедленного rollback kickstart получила37 (переход launchd ещё выполнялся); после завершения перехода read-only проверка подтвердила Core8000 operational с прежними исходниками. Новые исходники восстановлены из hash-bound NET02 source artifact; повторная активация ждала реального нового PID/health и сохранила identity digest. NET02-Core-2 complete02:28:12.722157→02:28:43.449579 UTC,Mac monotonic432968.055854083,30.727476s,launchctl exit0,Core operational. Backup/оба attempt reports в ignored `apps/node-agent/build/core-net02-before`. Привязка/БД/сертификаты вручную не менялись, камера не затрагивалась. Node ещё0.8.18-1, поэтому recovery на борту пока не доступен.
|
||||
|
||||
**NET02 build complete / P10 intent:** второй node-only build02:33:10.005328→02:37:33.619642 UTC,263.614309s. Первый npm fetch имел timeout45s; предусмотренный builder retry завершился за5.700275s с тем же lock. DG/Node UI checks/build, Go formatting, все Go race tests и binary/package complete; Go internal/node tests1.815s, полный test step с cold compilation67.461182s. Result archive SHA-256 `17a12086eb95f18b6dbb849ccb3206a116a186d30701c1669774798102d8330c`;34 flat regular outputs проверены и сохранены0600 в ignored qualified-net02. Node0.8.19:168260174 bytes,SHA-256 `e3334171b64e949eb435ea868ce275d32c8d063b3a471662991f791b138fd78d`.
|
||||
|
||||
P10 owner release `901a51d90299a44418ec7684`,168279607 bytes,SHA-256 `df36cd3e29b35eef34d6114fec08133b542639f3cd7d7a1da07f2c7e40e90b8b`. Штатный stage→APT plan→local owner authentication→Node package update, затем автоматический NET02 recovery и Core heartbeat acceptance. Проверяется сохранение Core/node/vehicle identity, tailnet endpoint, несколько новых heartbeat и Node service/no crash. X4 model preparation/SDK operations этим шагом не запускаются. Первый failed source staging очищен только original artifact --clean после сохранения reports: qualification SHAdbaacfadc8a192571033a046a25bc0c2ce85533d054705430e39b4be885e7ef8, outer report SHAfa89fdab020cc9bf8521cdd78383ba12d7270f07d53b13e25a15c37e22299221.
|
||||
|
||||
Дополнительная адресная read-only проверка из repository Python runtime Core подтвердила TCP к известному tailnet Node с tailnet source IP Mac; SSH_CONNECTION и системный маршрут согласованы. Один предшествующий TCP probe из другого системного Python завершился timeout; причина единичного timeout не установлена, это не доказательство отказа Tailscale. Никакие маршруты, VPN-настройки или адреса ОС не менялись.
|
||||
|
||||
**P10 pending owner authentication:** SHA проверен на Ubuntu. APT plan: только mission-core-node0.8.18-1→0.8.19,1 upgrade/0 new/0 remove. Открыто одно штатное окно «Mission Core Node · 0.8.19», launcher.stderr пуст, install-output.log создан; владелец получил один запрос на локальный OS password. Последняя проверка: квитанции завершения ещё нет, Node0.8.18-1/X40.1.3-2. Восстановление связи пока не принято; новая установка не объявлена завершённой.
|
||||
|
||||
Package scope сравнен с прежним qualified0.8.18-1: меняются только control version, Node binary и provenance; прежний packaging-revision.json удаляется. Все остальные payload bytes/modes совпали, включая model.deb/profile, SDK/sensor scripts и maintainer scripts. Оба source staging очищены только соответствующими artifact --clean; временных сборочных процессов не оставлено. Owner release и квалифицированные пакеты/отчёты сохраняются. Следующий шаг после пароля: прочитать P10 result/version, дождаться автоматического authenticated recovery, сверить прежние identities и новый tailnet endpoint, проверить несколько свежих heartbeat. Повторно открывать установщик или просить ещё одну подготовку X4 не требуется.
|
||||
|
||||
## P10 complete / NET03 — исправление цепочки recovery TLS10.09.2026
|
||||
|
||||
После ввода владельцем пароля P10 complete05:16:37.944641→05:16:52.757461 UTC,Ubuntu monotonic315705.82574643,14.812812s. Установлен Node0.8.19, SHA-256 `e3334171b64e949eb435ea868ce275d32c8d063b3a471662991f791b138fd78d`; Node/K1/RealSense active,NRestarts0,UI ready. X4 остаётся0.1.3-2. Однако Core offline: Node слушает tailnet:8781 и каждые30s отклоняет Core TLS с `x509: certificate signed by unknown authority`. Установка успешна, аппаратная приёмка NET02 не прошла.
|
||||
|
||||
Read-only сравнение публичных сертификатов из Core registry и recovery-client.pem: CA совпадает byte-for-byte, public key совпадает, подпись leaf подтверждается сохранённым CA. Причина в новом Python certificate profile: recovery leaf и CA имеют одинаковые Subject/SPKI и оба без SAN. Pinned Go1.26.8 `crypto/x509/verify.go:alreadyInChain` считает такую пару циклом и пропускает доверенный root. Исходный server leaf отличается IP SAN, поэтому прежний heartbeat не имел этого дефекта. Go unit fixture использовал другой Subject leaf и не покрывал фактический Python output; этот пробел приёмки признан.
|
||||
|
||||
NET03 меняет только Subject recovery client leaf на «Mission Core channel recovery», сохраняя issuer, CA, Core key и проверку mTLS.31 Core fleet tests passed. До активации запускается versioned interop artifact `9beb166a140dcbb75711f9e4`,66911019 bytes,SHA-256 `9ad9734f73b3ee70a61b5085372c41ebb327396d50fd69b152358aa25fa2aff5`: публичные синтетические CA/legacy/fixed certificates, выпущенные тем же Python кодом, проверяются pinned Go1.26.8. Отрицательный контроль обязан воспроизвести UnknownAuthorityError; исправленный leaf обязан пройти и сохранить public key. Артефакт не содержит реальных ключей/сертификатов и не открывает SDK/USB. Unprivileged staging/cgroup1GiB/150% CPU/128tasks/180s; зависимостей ОС/установленных файлов не меняет, Go network downloads отключены.
|
||||
|
||||
После успешного interop — обновление только канонического Core8000 с наблюдением фактического завершения restart до55s. Node0.8.19 повторно не устанавливается; старые ключи/привязка сохраняются, новый краткоживущий leaf выпускает сам Core при запуске. Затем требуется настоящее восстановление и несколько свежих mTLS heartbeat, с сохранением identity и tailnet endpoint. Камера/model preparation остаются вне NET03.
|
||||
|
||||
Первый interop artifact остановился до запуска Go: guard распаковки не допускал корневой directory member `go` без завершающего slash. Установленная система не менялась; failed staging сохранён как свидетельство. Guard теперь проверяет компоненты пути, запрещает absolute/parent traversal и допускает штатный корень. Начальный report записывается до распаковки. Новый artifact `47a5142c4707d4b54693fdc6`,66911479 bytes,SHA-256 `3354f56e1024a5f549d38c04a7af6fd00340014ec9f15d1cfb81568cfe981d51`; прежние resource limits/negative control сохранены. Это исправление test harness, не дополнительная установка Node.
|
||||
|
||||
**NET03 interop complete:**05:33:51.694243→05:34:17.728311 UTC,Ubuntu monotonic316739.575316337,26.034068s,exit0. Legacy profile воспроизвёл UnknownAuthorityError, fixed profile принят, Core public key сохранён. stderr пуст. Report сохранён0600 локально; успешный staging удалён только своим artifact --clean. Первый failed staging с публичными synthetic inputs сохранён без активных процессов; установленная система и SDK не затрагивались.
|
||||
|
||||
**NET03 Core activation complete:**05:35:10.596393→05:35:42.362973 UTC,Mac monotonic438990.62500025,31.766344s. Один штатный launchd kickstart, exit0; новый канонический процесс Core8000 operational, прежние identities совпали. Source recovery.py SHA-256 `c83ece4b01ddf9ddb47e1cb0be992ec42cf481ea67b8da34222d783b2b409c36`. Node повторно не устанавливался. Authenticated inspect после активации подтвердил сохранённый Node tailnet endpoint и revision1, прежний binding; TLS rejection исчез.
|
||||
|
||||
Первый heartbeat acceptance05:36:13→05:36:31 UTC ещё показал offline. Read-only диагностика: один обратный TCP connect к Core8782 завершился timeout3s; Tailscale ping к тому же Mac прошёл, собственный клиент Running/Online, ShieldsUp=false, macOS firewall disabled. Просмотр сокетов и секундный sample собственного Core процесса сохранили свидетельство; настройки сети/ACL/служб не менялись. К05:39 UTC heartbeat восстановился автоматически. Причина дополнительной задержки не локализована; мгновенное восстановление и полная fault matrix не заявляются. Отдельный TCP timeout не приписывается автоматически Tailscale или firewall.
|
||||
|
||||
**NET03 hardware channel acceptance passed:**05:40:08.583622→05:40:32.647644 UTC,Mac monotonic439288.609984333→439312.673681458. Пять последовательных snapshots online, пять разных свежих heartbeat, tailnet endpoint/revision1; CoreID/NodeID/vehicleID и SHA существующего bindingID совпали с baseline. Node0.8.19 active/running,NRestarts0; X4 остаётся0.1.3-2. Final check05:42:00 UTC: Core8000 operational, Rover006 online со свежим heartbeat, Core channel слушает собственный tailnet IPv4:8782; на8765 backend отсутствует. UI build не повторялся для неизменных frontend bytes. Scoped Ruff и git diff --check passed.
|
||||
|
||||
Private evidence в ignored `apps/node-agent/build/core-net03-before`,0600: interop-report.json SHA-256 `c323c06fe884ef1b33bf531f93e61b71625f1cd30ad9be396596daa96437311e`; activation.json `a5f52b13c67585fda13121e76fe38baabc4b1a5a45ce2e1ba4803951e581d0d6`; первая failed heartbeat-acceptance.json `cc1f5cb186b7e2d6a157dc5c91123861e9dc5afe391a58268cfd10ec832f4bf3`; принятая heartbeat-acceptance2.json `7ad1c0e85a1074ecf77ef58f5678d42522d68ef2aad5ff472b0c0a82bb26f61b`; core-sample.txt `d2eb0da65b56c22d14b66333787877b1e81b20f979277f4686dee83c50d4fa66`. Привязка/БД вручную не изменялись; временных работающих проверок не осталось. Пароль больше не требуется. Следующий камерный шаг — подготовка B11 из bundled model и аппаратная проверка видео; NET03 не отправлял SDK/preview/SD commands и не закрывает эту отдельную приёмку.
|
||||
|
||||
## AUT01 — оценка автономности и USB power,10.09.2026
|
||||
|
||||
По запросу владельца проведены только read-only действия: текущие health/fleet API,
|
||||
код supervisor/worker/bridge и pinned SDK headers, DMI/sysfs/lsusb известного Ubuntu,
|
||||
официальные инструкции X4 и README uhubctl. Борт Macmini6,2; X4 напрямую на xHCI
|
||||
USB3,5000M. Есть kernel per-port disable и USB2/USB3 peer mapping, но это не
|
||||
доказательство физического VBUS off. uhubctl отсутствует; unprivileged hub descriptor
|
||||
read недоступен. Первая форматирующая команда вывода остановилась на NameError
|
||||
после lsusb tree; повторное чтение исправило только quoting и ничего не изменило
|
||||
в системе. Root read/install, reset/replug/power cycle и SDK-команды не выполнялись.
|
||||
|
||||
Текущий код автоматически создаёт новую службу вернувшегося exact USB экземпляра,
|
||||
но не повторяет failed SDK Open и не восстанавливает preview intent после новой
|
||||
сессии. Restart=no/40s startup watchdog остаются ограничением текущей автономности.
|
||||
Производитель документирует память Android-режима и USB power-on X4, но не её
|
||||
USB power-off; эти свойства ещё не квалифицированы на текущем hardware/firmware.
|
||||
План11 отделяет software reconnect, холодный старт и физическое питание, ставит
|
||||
их после B11 перед расширением настроек/файлов. Все будущие изменения Ubuntu
|
||||
предписаны через versioned artifact, без широкого controller reset.
|
||||
|
||||
Final snapshot06:03:19.176974 UTC,Mac monotonic440679.221301416:
|
||||
Core operational,Node online,X4 connected/preview1/recording0. Это состояние SDK,
|
||||
не проверка свежих отображённых кадров. Redacted report0600 в ignored
|
||||
`apps/node-agent/build/autonomy-aut01/readonly.json`,SHA-256
|
||||
`16fd3be1d35c3d8bc34f725ed3213c9b0931935f78961c54a6b3c182f8513e2d`.
|
||||
Канонический Core8000 оставлен работающим; новых постоянных процессов/зависимостей
|
||||
не создано. Исходные pasted советы не исполнялись как инструкции.
|
||||
|
||||
## SAVE01 — фиксация текущей реализации10.09.2026
|
||||
|
||||
Владелец поручил закоммитить всё выполненное, включая Design Guideline, и обновить
|
||||
Ops перед следующим этапом.11 изменённых DG files byte-for-byte совпали с NET02
|
||||
qualified source; сохранены commit8c53f73ee521561a1cc7944a7f7cf113702f40b5.
|
||||
Core использует этот exact DG commit в обоих Node build entrypoints.116 Core
|
||||
files совпали с NET02 source; позднейшие изменения — проверенный NET03 certificate
|
||||
profile/regression/interop и инженерные документы AUT01. Прежние аппаратные
|
||||
receipts/provenance остаются неизменными. В stage входят только source/docs/tests,
|
||||
без SDK binaries, собранных пакетов, raw evidence и ключей. Изменение Git/pin не
|
||||
перезапускает Core или службы Ubuntu. После фиксации — официальный прямой Ops MCP,
|
||||
отдельная X4 рабочая карточка и comment к неизменному baseline MISSIONCOR-76.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Insta360 X4 — публичные исходники и SDK
|
||||
|
||||
08.09.2026. Владелец отказался от заявки поставщику и поручил искать публичный GitHub-вариант. Позднее расширил scope с просмотра до управления камерой: запись/стоп, фото, настройки, состояние и работа с файлами. Ниже — статический аудит, без запуска SDK и без изменений Ubuntu.
|
||||
|
||||
## Найденные варианты
|
||||
|
||||
| Источник | Проверенное содержимое | Решение |
|
||||
|---|---|---|
|
||||
| [pdxmusic/insta360sdk](https://github.com/pdxmusic/insta360sdk/tree/3db9641ba612c639db10591d1402231662a1eb5d) | CameraSDK 2.1.8, Linux x86_64; получены реальные 17 031 504 bytes из публичного Git LFS, hash совпал с pointer; заголовки и JSON конфигурации | Основной кандидат для дальнейшей квалификации |
|
||||
| [giacomotambe/INSTA360-X4-ROS2](https://github.com/giacomotambe/INSTA360-X4-ROS2/tree/c4cc029cffba14f5cbc4d34f3dfaaeb7c5b4a55f) | Настоящая x86_64 библиотека 11 518 472 bytes и заголовки X4; SHA-256 `e1341d1921b6f207d506df293ab5f33039af1da6bb06843b77a13e1b13b13d01` | Резервный кандидат; не смешивать его headers с 2.1.8 |
|
||||
| [Exusiai-R/Insta360Playground](https://github.com/Exusiai-R/Insta360Playground/tree/23aebf21ca2f6a2813e89c68c82a442cbe44ea92) | Библиотека 11 872 744 bytes, ELF machine 183 (AArch64); SHA-256 `01d59fc508fb8eb77a1e8726f6ffae960fb5b084c7c82eddddab7a2625b11c8b` | Не подходит Intel Mini |
|
||||
| [caxapok44/CameraSDK-Cpp](https://github.com/caxapok44/CameraSDK-Cpp/tree/853f2c5efbd18383f2d8fa795c8eb6b72324cc20) | В дереве есть `.so`, но enum CameraType в комплектных headers заканчивается X3 | Не выбран для X4 |
|
||||
| [ai4ce/insta360_ros_driver](https://github.com/ai4ce/insta360_ros_driver) и современные непосредственные forks Desktop-CameraSDK-Cpp | Wrapper/demo/docs; актуальный upstream ROS требует отдельно добавить SDK | Сам по себе не решает получение runtime |
|
||||
| [xaionaro-go/insta360ctl](https://github.com/xaionaro-go/insta360ctl/tree/f94193ce03c5af0921a9992bfd1af6bd946150d0) | Исходники Go BLE control и Wi-Fi streaming, X4 в model table; подробная аппаратная документация преимущественно GO 3 | Исследовательский резерв. Это не проверенный USB X4 adapter; не выполнять GO 3 команды на X4 по совпадению номеров |
|
||||
| [kashmir2003/insta360-usb-native](https://github.com/kashmir2003/insta360-usb-native/tree/44204912a756325bf7f3452c4959b20a4c0712d1) | Независимый Python USB preview для ONE R. Сам автор требует отдельного вывода протокола для X-series | Не считать drop-in драйвером X4; чужие handshake/retry/root scripts не запускались |
|
||||
|
||||
Проверены деревья девяти непосредственных SDK forks с изменениями 2024–2026 годов и дополнительные X4/ROS/USB проекты. Это ограниченный поиск, а не утверждение, что исследованы все GitHub репозитории.
|
||||
|
||||
## Основной кандидат
|
||||
|
||||
Источник: public third-party mirror, commit `3db9641ba612c639db10591d1402231662a1eb5d`.
|
||||
Каталог: `CameraSDK-2.1.8-20260828_171805-linux-x86_64`.
|
||||
`libCameraSDK.so`: SHA-256 `6d20aca1930101293308c056cef552c0beb8cbf1d6c7d567a79e95a52f9b1373`.
|
||||
Точный состав 17 файлов зафиксирован в `plugins/insta360-x4/packaging/sdk-lock.json`; бинарники в Git не добавлены.
|
||||
|
||||
Статическое чтение ELF64 подтвердило machine 62 (x86_64). Прямые `DT_NEEDED`: `ld-linux-x86-64.so.2`, `libc.so.6`, `libgcc_s.so.1`, `libm.so.6`, `libpthread.so.0`, `libstdc++.so.6`. Максимальные найденные ABI версии: GLIBC 2.17, GLIBCXX 3.4.30, CXXABI 1.3.13. Это проверка структуры файла; она не доказывает отсутствие runtime `dlopen`, доступ к USB, полноту dependencies или совместимость firmware.
|
||||
|
||||
В полученных headers доступны `StartRecording`, `StopRecording`, `TakePhoto`, `StartLiveStreaming`, `StopLiveStreaming`, `GetCameraFilesList`, `DownloadCameraFile`, параметры съёмки и per-model capability queries. `DeviceDiscovery` прямо предупреждает, что enumeration может устанавливать соединения с камерами; поэтому будущая изоляция выбранного USB-экземпляра должна действовать **до** SDK enumeration, а не только перед `Camera::Open`. В `DeviceConnectionInfo` есть opaque native pointer; нельзя переносить его между процессами или сохранять в journal.
|
||||
|
||||
Совпадение SHA с LFS доказывает целостность относительно публичного mirror, но не подпись производителя. В просмотренных SDK-файлах не найден документ условий распространения. Apache-2.0 у ROS wrapper не является доказательством лицензии на vendor `.so`. Внешняя поставка runtime остаётся отдельным release gate; SDK не публиковался от имени NODE.DC.
|
||||
|
||||
## Webcam и выбор управления
|
||||
|
||||
[Актуальное руководство X4](https://onlinemanual.insta360.com/x4/en-us/camera/appuse/obs) описывает USB Webcam Mode и 2880×1440/30 с OBS, с оговоркой о непроверенном другом ПО. Этот факт исправляет предположение, что X4 не даёт USB webcam video. Однако он не даёт настройки/запись/файлы через USB Camera SDK и не закрывает расширенную задачу владельца. Linux UVC, одновременная запись и webcam, аппаратная сшивка и задержка не проверены.
|
||||
|
||||
Выбранное направление остаётся USB Camera SDK adapter с раздельными preview/recording состояниями, общим Node-owned prepare из обеих UI, полным installer ownership и per-instance isolation. SDK Media/stitching не добавляется.
|
||||
|
||||
## Проверки и следующие зависимости
|
||||
|
||||
- Инструмент сборки `fetch_sdk.py` импортировал уже скачанный SDK по полному lock; повторная проверка не требует сети и не исполняет библиотеку.
|
||||
- Не запускались `ldd`, SDK demo, SDK `Open`, запись, preview, BLE или Wi-Fi commands. Не выполнялись сторонние install scripts.
|
||||
- SSH к настроенному Worker 006 для проверки Linux build runtime завершился timeout; рабочая станция сборки не изменилась. На Mini компилятора/SDK вручную не устанавливали.
|
||||
- Последующая директива владельца заменила build-host выбор: всё собирается и тестируется на имеющейся Ubuntu Mini. B01 собрал native adapter; B05 собрал `.deb` и проверил cold Python imports/static closure. Компилятор уже был установлен; ничего вручную не доустанавливали. Unit/mount/USB isolation и аппаратная квалификация ещё ожидают установки P01. Актуальные hashes/attempts — в installation ledger. Полноценная готовность управления/видео не заявляется.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Insta360 X4 — состояние реализации 10.09.2026
|
||||
|
||||
## Кандидат исправления09.09.2026 — B11/P09
|
||||
|
||||
Подготовлен X40.1.3-3 в Node0.8.18-1.38 Ubuntu tests passed, включая real-codec stream с единственным initial keyframe после повторного START и active verify двух новых кадров общего decoder. Повторный START больше не сбрасывает generation при уже активном preview; unknown такого повторного START не закрывает существующий decoder. Idle verification остаётся в worker, active verification использует broker source; единый verification journal сохраняет дедупликацию при изменении preview state. Собственные причины ошибок доступны через allowlisted messages.
|
||||
|
||||
**Node0.8.19 установлен через P10; X4 model0.1.3-3 ещё не применён и аппаратно не принят.** B11 bundle сохранён в новом Node, установленный model остаётся0.1.3-2. Нужны штатный Core model update и аппаратный START→повторный START→verify→viewer reopen. Наличие конкретного исправленного дефекта не доказывает, что устранены все причины прежнего зависания.
|
||||
|
||||
**NET01–03,10.09: связь Rover006 восстановлена через Tailscale.** Первоначально Node сохранял прежний LAN IPv4 Mac; наличие Tailscale не меняло этот URL. P10 добавил authenticated recovery, но первая аппаратная проверка выявила несовместимый Subject нового Core client certificate с Go x509. NET03 исправил выпуск сертификата в Core, сохранив CA/ключи. Node автоматически сохранил tailnet endpoint с revision1; обычные mTLS heartbeat подтвердили online. CoreID, NodeID, vehicleID и bindingID сохранены. Подробности и границы проверки в ledger.
|
||||
|
||||
**P10 complete,10.09 в05:16:52 UTC; NET03 Core активирован05:35:42 UTC.**31 fleet tests passed; отдельная проверка реального Python certificate profile через pinned Go1.26.8 воспроизвела legacy failure и приняла fixed profile. Node0.8.19 прошёл DG/Node UI checks/build и все Go race tests. Первая проверка heartbeat сразу после Core restart ещё не прошла; к05:39 UTC online восстановился автоматически. Причина дополнительной задержки обратного TCP отдельно не установлена, мгновенное восстановление не заявляется. Node service active,NRestarts0. Повторный ввод пароля и переустановка не требуются.
|
||||
|
||||
**Установлено на имеющейся Ubuntu:** Node **0.8.19**, X4 Debian package **0.1.3-2**, model protocol **0.1.3**. P10 изменил Node binary/provenance, остальные payload bytes совпали с P09. PREP04 ранее развернул X4 из удалённого Core8000 → настройки X4 → «Обновить». Core8000 использует квалифицированный UI N09 и исправление NET03. Восстановление канала принято; устойчивость видео и применение B11 остаются открытыми.
|
||||
|
||||
**Новый открытый случай UI01 после P08:** собственный повторно подключённый зритель получил1280×640, затем currentTime остановился на4.846 s и появился статус «Нет свежих кадров». Следующее открытие не получило первого кадра; штатный verify завершился error. Камера продолжает сообщать online=true, preview1, recording0. Причина не установлена; временная последовательность после обновления не доказывает его причинность. Захват владельца не перезапускался, закрыты только собственные peers. Разбор этого случая становится первым следующим пунктом.
|
||||
|
||||
**LIVE05 восстановление после пользовательского «нет картинки»:** один явный штатный Core preview STOP→START complete восстановил браузерное1280×640 видео. Отдельный приёмник WebRTC подтвердил1107 кадров за98.19 s наблюдения,11.28 fps между первым и последним непустыми samples; все49 непустых samples имели fresh=true по телеметрии источника. Это восстановление работающего потока, а не устранение причины исходного зависания. Новый runtime не устанавливался, службы не перезапускались, SD recording0.
|
||||
|
||||
Во время того же успешного потока verify вернул error, видео продолжилось. Следовательно, текущий verify не является надёжным критерием отсутствия работающего preview: он открывает новый decoder посреди encoded stream. Operations дополнительно скрывает собственное сообщение verification под общей ошибкой unsupported parameter. Эти два дефекта проверки остаются открыты; error нельзя интерпретировать как отсутствие кадров в уже работающем shared decoder.
|
||||
|
||||
**Живое видео принято в ограниченной аппаратной проверке LIVE04:** первый запуск без SD recording дал браузеру H264 **1280 × 640**, readyState4, paused=false, currentTime10.173→49.014 s. Закрытие и повторное открытие зрителя при активном capture снова дали изображение. Явные preview STOP → START также дали изображение, currentTime6.855→51.380 s. SD recording оставалась0. Тестовый preview остановлен штатной командой. Это не измерение camera-to-display latency, не длительный soak test и не проверка чистого образа Ubuntu.
|
||||
|
||||
Предыдущие B09/P06/PREP03 не исправляли первый запуск: сохранения SPS/PPS оказалось недостаточно. В принятом B10 декодер запускается **до** явного preview.start и сохраняется между зрителями, пока capture активен. H264 preferences задаются до обработки offer. Операции камеры остаются в per-instance worker journal; создание и закрытие WebRTC peer не отправляет START/STOP записи. Подтверждён результат исправления; внутреннее поведение encoder firmware отдельно не измерялось.
|
||||
|
||||
Все версии, намерения до исполнения, ошибки и timestamps находятся в [installation ledger](07_INSTA360_X4_INSTALLATION_LEDGER.md). Private receipts, serials, media identifiers и SDP остаются в ignored build directories с правами0600. Базовый Core HEAD54a85fdf5005803a7834482b7516ebe6b6fb142b. По поручению владельца10.09 фиксируются текущие изменения Core и DG; общая loading dependency закреплена на DG8c53f73ee521561a1cc7944a7f7cf113702f40b5. Это фиксация квалифицированных исходников; исторические provenance установленных пакетов не переписываются.
|
||||
|
||||
## Требование владельца
|
||||
|
||||
X4 — операторский несшитый просмотр и управление камерой. Preview и SD recording имеют отдельные команды и lifecycle. Инициализация доступна из Node и удалённого Core. Модель и физический экземпляр разделены: несколько одинаковых камер не должны объединять identity, session или результаты операций. Stitching, AI, SLAM и управление ровером в этот этап не входят.
|
||||
|
||||
Каждая предпосылка Ubuntu должна исполняться через versioned installer или штатный device profile с первого эксперимента. Ручной ремонт установленной системы через apt/pip, перенос SDK libraries, chmod, environment override или patch службы не применялся. Сборки и аппаратные проверки выполнялись на той же Ubuntu Mini; отдельный Worker и Docker не использовались для X4.
|
||||
|
||||
## Реализация и доказательства
|
||||
|
||||
| Область | Реализовано | Проверено / ограничение |
|
||||
|---|---|---|
|
||||
| Модель и экземпляры | Registry для K1, D455, X4; X4 требует USB VID/PID и exact product. Stable instance ID; неоднозначная идентичность не допускает init. | Node установлен;500 synthetic inventory summaries проходят carrier/validator. Несколько физических камер и миграция D455 к отдельным процессам не приняты. |
|
||||
| Подготовка из двух UI | Общая prepare operation, модельная установка coalesced; selected-instance verification отдельно. Прогресс связан с device/operation IDs. | Go/Core integration tests; реальное обновление из удалённого Core PREP02–04 complete. Local Node GUI first-init на чистом образе ещё не проверен. |
|
||||
| Изоляция | DynamicUser per-instance worker, USB BindPaths/DeviceAllow, NoNewPrivileges, отдельный network namespace; broker вне namespace SDK. | Реальный worker прошёл admission до SDK Open. Replug и несколько реальных экземпляров остаются в acceptance matrix. |
|
||||
| Операции | Device/session/deadline admission, durable UNKNOWN до эффекта, immutable digest и receipts. Per-device locks; preview и SD независимы. | Synthetic tests; на hardware подтверждены preview START/STOP, status/settings read, verify и SD START/STOP. |
|
||||
| Живое видео | Bounded binary IPC, codec headers per stream/generation, ранний decoder, общий источник между зрителями, отдельный encoder peer. | LIVE04: H2641280×640 в удалённом браузере, первый старт, viewer reopen и capture restart без SD. Output ограничен≤15Hz, actual FPS/latency не измерены. |
|
||||
| Ресурсные пределы | До4 активных источников, до4 peers суммарно и до2 на камеру; latest-frame slot. | Это предел активного media на Mini, а не количества устройств в реестре.500 одновременных видеопотоков не поддерживаются/не заявлены. |
|
||||
| Настройки | Capabilities/readback, ограниченные video/photo resolution, mode, WB, exposure mode и ISO setters с проверкой зависимости. | Реальные чтения firmware/settings работают; writes ещё не проверены на камере. Shutter/EV setters и полный набор SDK режимов не реализованы. |
|
||||
| Съёмка и файлы | SD start/stop, photo operation, bounded catalogue pages по32. | SD01: START и STOP complete, получен путь одного созданного файла. Файл оставлен на SD. Фото и download contents не приняты; download ещё не реализован. |
|
||||
| Авторазвёртывание | Node.deb содержит fixed profile и model.deb; model.deb содержит native adapter, SDK и26 locked wheels. Hash-checked offline Python payload, compiled runtime. | P01–P07/PREP02–04 на текущей Ubuntu; cold imports/static ELF closure. Оператору не нужны GCC/Go/npm. Полная проверка зависимостей на чистом OS image остаётся открытой. |
|
||||
|
||||
Форматирование, удаление файлов, firmware update и power/reset не включены. SD recording не повторялась для запуска LIVE04. SDK boolean capture status и поведение firmware при потере USB требуют отдельной аппаратной проверки.
|
||||
|
||||
## Версии и состав принятого артефакта
|
||||
|
||||
- Node.deb0.8.18-1:168257000 bytes, SHA2569e5aaff4a8e4a2ed24cef56bcc9bd9721971df3154fa48be8595fef8591bdbb3. P09 complete09.09.2026 в04:14:52.735120 UTC; binary/UI0.8.18 сохранены.
|
||||
- X4.deb0.1.3-2:58198804 bytes, SHA2562931a5206478fcac67f74bd1fb579b98c0fcc43eda4c05e5ac4a721947619130, runtime revisionf78a7ae557e3371022869025.
|
||||
- Owner release38a5e2135801b252e4e194bd; P08 complete17:23:23.914094→17:23:39.803770 UTC,15.889641 s; UI ready, Node/K1/RealSense active, NRestarts0. X4 package bytes не менялись, повторный prepare не выполнялся.
|
||||
- PREP04 profile complete6.955230 s; selected verify:2 decoded H264 frames2880×1440,317 ms. Это длительность проверки, не WebRTC latency.
|
||||
|
||||
CameraSDK2.1.8 Linux x86_64 получен из [pdxmusic/insta360sdk, pinned commit](https://github.com/pdxmusic/insta360sdk/tree/3db9641ba612c639db10591d1402231662a1eb5d). Hashes всех17 файлов закреплены в sdk-lock.json. Native bridge Linux ABI собран и проверен на Mini; vendor binary не менялся в B09/B10. Mirror не заменяет vendor signature или разрешение на распространение. Источники и лицензионные неизвестные — [SDK audit](08_INSTA360_X4_SDK_SOURCE_AUDIT.md).
|
||||
|
||||
## Квалификация сборки
|
||||
|
||||
- B10:33 Ubuntu package tests, включая реальный synthetic H264 loopback с одним initial keyframe и поздним подключением зрителя, prime→SDK START, SDK STOP→release, peer/source lifecycle. Cold imports и статическая ELF closure прошли.
|
||||
- N08: Node Go race tests и Node UI checks прошли; Core819 unit tests прошли, один прежний K1 RRD test исключён из-за отсутствующего Python Rerun и не считается принятым.
|
||||
- N09/N09-R1: DG typecheck/registry/4 loading tests/catalog, Node UI/Go race tests/build, Core architecture/typecheck/819 tests/build прошли. Первые6 Core failures были несовместимыми HTML regex в двух K1 presentation tests; после исправления assertions полный819 suite повторён успешно. Прежний Rerun-dependent case по-прежнему не входит в эту квалификацию.
|
||||
- Production Core/Node UI собраны на Ubuntu. Повторные Node revisions меняли packaging/profile/model payload; полная frontend/Go пересборка не выполнялась без необходимости. Квалифицированный Core dist используется на8000.
|
||||
- Первичные scoped Python проверки:41 passed, включая control/native simulator/SDK source/fleet pairing. Native simulator не загружает vendor SDK.
|
||||
- Ruff и git diff --check прошли; private build/evidence directories исключены из Git.
|
||||
|
||||
## Открытые границы
|
||||
|
||||
1. Чистый Ubuntu image: установка OS dependencies и первый prepare через UI. Нынешняя рабочая Mini не заменяет эту проверку.
|
||||
2. Реальная camera-to-display latency, FPS, длительная работа, replug, disconnect и несколько физических камер.
|
||||
3. Ubuntu WebKitGTK2.52.6 не предоставляет RTCPeerConnection в проверенном Node shell. Локальная подготовка/verify доступны; local UI WebRTC не принят. Удалённый Core browser подтверждён.
|
||||
4. Полный набор управления камерой: hardware acceptance setters/photo и реализация download. D455 instance-process migration — отдельная незавершённая часть multi-camera архитектуры.
|
||||
5. Право внешнего распространения vendor SDK. Пакет пока локальный, внешней публикации не было.
|
||||
|
||||
## Отчётность
|
||||
|
||||
В [ledger](07_INSTA360_X4_INSTALLATION_LEDGER.md) сохранены также неудачные попытки: postinst pager hang, local APT pathname failure и отсутствие первых кадров в B09. Исправлялись versioned artifacts, после чего повторялся штатный путь установки. Установленная Ubuntu вручную не патчилась.
|
||||
|
||||
Автоматическая проверка ранее отклонила создание Ops-карточки, сочтя передачу внутренней архитектуры/статуса в выбранный проект неподтверждённой. Карточка не создана, обходных записей не было; инженерная отчётность остаётся локально. Это ограничение публикации, не блокировка текущей установки и аппаратной проверки.
|
||||
|
||||
## Последующий UI-инкремент
|
||||
|
||||
UI01/N09 завершён как общий DG loading contract и поставлен в Core8000 и Node0.8.18: Button.loading/IconButton.loading, LoadingRegion, registry/docs/catalog и общий sensor frontend. Browser QA: initiating button сохраняет размеры, spinner центрирован; video остаётся смонтированным; overlay следует normal/expanded области; Escape восстанавливает размер. До P08 движущееся видео1280×640 подтверждено, после кадра0 loaders. DG визуально проверен в dark/light и с двумя accents. В обнаруженном после P08 отказе loader также исчезает, verify button освобождается; это подтверждение UI error lifecycle, не успешная video acceptance.
|
||||
|
||||
Новая embedded UI установлена на Ubuntu, но визуальный проход внутри локального Node/WebKit и локальное видео остаются открыты. Порядок дальнейшей работы: [следующие шаги](10_INSTA360_X4_NEXT_STEPS.md).
|
||||
@@ -0,0 +1,44 @@
|
||||
# Insta360 X4 — следующий план после первого живого просмотра
|
||||
|
||||
Срез10.09.2026. Основания: исходный Mission Core Node Context, разделы8/25/26/29; [план X4](06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md), [текущее доказательство](09_INSTA360_X4_IMPLEMENTATION_STATUS.md), [журнал](07_INSTA360_X4_INSTALLATION_LEDGER.md), последние решения владельца. Исторический нулевой baseline не отменяет PREP04/LIVE04; наличие live stream не закрывает весь P4/P4C/P7.
|
||||
|
||||
## Подтверждённая исходная точка
|
||||
|
||||
X4 подключена к SDK на той же Ubuntu Mini. Удалённая подготовка через Core разворачивает встроенный model package и проверяет exact instance. Без SD-записи работают первый WebRTC START, viewer reopen и STOP→START;1280×640, несшитые изображения двух объективов. SD START/STOP отдельно подтверждены ранее. Это одна физическая камера и ограниченная лабораторная проверка.
|
||||
|
||||
Дополнение LIVE05: пользовательский stale preview восстановился после одного явного STOP→START,100-секундный приём подтвердил свежие кадры. Причина исходного зависания ещё не исправлена. Verify во время живого потока дал ложный отрицательный результат; ближайшая реализация должна проверять свежий общий decoder при активном preview и сохранять адресную причину ошибки, не создавая второго владельца SDK/не перезапуская capture неявно. Не путать это восстановление с завершённой приёмкой устойчивости.
|
||||
|
||||
B11/P09,09.09: эта реализация готова и прошла38 Ubuntu tests. Дополнительно исправлен доказанный code path, в котором idempotent повторный START очищал feed без нового keyframe. Проверка10.09 подтвердила установку Node0.8.18-1; model0.1.3-3 ещё требует штатной подготовки, затем аппаратного подтверждения повторного START, live verify и повторного viewer. Остальные причины возможных разрывов и длительная устойчивость остаются в плане.
|
||||
|
||||
Предварительное условие NET01 закрыто NET02/NET03,10.09: Core–Node восстановили канал через Tailscale с прежними trust/identity. В Core подтверждены несколько свежих mTLS heartbeat, endpoint revision1. После восстановления заново читать актуальное состояние камеры перед любыми preview/model операциями; прежний stale inventory не подтверждает её текущее состояние.
|
||||
|
||||
P10 complete: Node0.8.19 установлен, X4 остаётся0.1.3-2; повторный пароль не требуется. NET03 исправил Core certificate profile, проверенный Python→Go interop и на текущей паре. Следующий шаг по камере — штатная подготовка bundled model0.1.3-3 и аппаратная проверка повторного START/live verify/viewer reopen. Дополнительная задержка восстановления после NET03 пока не локализована: мгновенное восстановление или вся fault matrix не объявляются принятыми.
|
||||
|
||||
## Очередь реализации
|
||||
|
||||
**Уточнение владельца10.09 после NET03:** ближайший приоритет после применения
|
||||
B11 — автономность подключения и питание. Проверить сохранение Android-режима,
|
||||
replug/cold start, автоматическое восстановление SDK и просмотра; определить
|
||||
реальное VBUS switching существующего Mac mini. Это предшествует расширению
|
||||
camera settings/file download. Конкретная матрица, доказательства и ограничения
|
||||
в [плане автономности](11_INSTA360_X4_AUTONOMY_AND_USB_POWER.md); прежняя очередь
|
||||
ниже сохраняет остальные работы.
|
||||
|
||||
| Порядок / связь с исходным планом | Работа | Конкретный выход |
|
||||
|---|---|---|
|
||||
| Завершено, P5 | Общие состояния loading в DG: pending инициирующей кнопки, центр content region, отсутствие бесконечной загрузки после ошибки. Shared frontend для Core и Node, каталог и правила. | UI01/N09 установлен в Core8000 и Node0.8.18. Сборки/819 Core tests, DG tests/catalog и Node checks прошли. Browser geometry, normal/expanded/Escape, light/dark и error lifecycle проверены. Native Node visual QA ещё открыта. |
|
||||
| Первый следующий, P3/P4 | Разобрать UI01 stale-frame случай: после P08 повторный viewer застыл на4.846 s, следующее открытие без кадров, verify error при online/preview1. Сопоставить SDK packet sequence, decoder freshness, peer delivery и browser frames; воспроизведение в согласованном окне без скрытого рестарта захвата. | Локализованная причина и проверенное исправление через versioned artifact; повторный просмотр и работа после обновления/долгого capture дают свежие кадры. Не считать новый случай автоматически следствием UI или Node update. |
|
||||
| Следующий, P4 + P5 | Совместимый локальный просмотр в установленном Node/WebKit. Исходный план предусматривает HTTP/fMP4 carrier от той же медиа-сессии; переиспользуются общие media lifecycle/lease части K1, без импорта K1 protocol или второго SDK владельца X4. | На Ubuntu в приложении Node есть движущееся изображение; одновременно открыт удалённый Core. Закрытие одного зрителя сохраняет другого, не меняет SD recording. Все runtime dependencies входят в .deb/profile. |
|
||||
| Затем, P4C | Матрица поддержанных действий по фактическим SDK headers и capabilities: camera settings, normal photo/video, ISO/exposure/WB/resolution, отдельные SD start/stop и фото. Проверять readback, mode dependencies и отображение результата. | Явные адресные команды из обеих UI, реальные результаты на камере, неизвестный outcome не переигрывается автоматически. Не обещать все режимы SDK до их проверки; shutter/EV setters и неподдержанные поля перечисляются отдельно. |
|
||||
| P4C + общий source-record workflow | Получение содержимого выбранного файла на Node и в Core, bounded transfer, прерывание/продолжение, длина/hash и происхождение. Каталог уже есть, download ещё нет. | Пользователь получает существующий файл без shell; повторы/обрыв связи не меняют оригинал на SD. Незавершённый записываемый файл обрабатывается явно. |
|
||||
| P3/P4/P5 | Устойчивость:30 минут выбранного профиля, displayed FPS и glass-to-glass latency p50/p95/max, CPU/RAM/USB/network; idle/start/stop, stale, закрытие Core, loss/recovery и replug. | Измеренный профиль, честные статусы, ограниченные очереди; журнал отличает реальные тесты от synthetic. Физические отключения требуют отдельного согласованного окна, не выполняются фоном во время работы владельца. |
|
||||
| P1/P3/P6 | Несколько одинаковых X4 и D455, одновременная K1, exact-device routing, isolation/crash/replug. D455 shared-process migration остаётся отдельной реализацией. |2/4 реальные камеры независимы;500 synthetic descriptors не превращаются в обещание500 потоков. Текущий admission4 sources/4 peers/2 peers per camera пересматривается только по измерениям. При отсутствии устройств hardware часть остаётся открытой. |
|
||||
| P7 | Чистая Ubuntu24.04 Desktop amd64 с закреплённым образом; полный installer → discovery → prepare из обеих UI → image → управление; upgrade/repair/rollback/failure matrix. | Никакой системной предпосылки вне installer, repeat/recovery сохраняют identity/trust/recordings. Текущий рабочий диск Mini не стирается. Отдельный чистый носитель/образ — необходимое условие, не предположение о наличии второй машины. |
|
||||
|
||||
Критический путь следующего рабочего инкремента: **зависание свежих кадров → локальное видео Node → приёмка управления/фото → получение файлов**. Контракт и учёт clean install проверяются при каждом изменении, финальная установка на чистый носитель завершает воспроизводимость. Общее наблюдение K1/monitor и их открытые fault/backfill/source-import проверки сохраняются в исходной матрице; успех X4 их не закрывает.
|
||||
|
||||
Stitching не включается: текущая цель — операторский обзор с низкой задержкой. Worker, AI и управление движением не являются зависимостями. Если нужен сшитый обзор позднее, это отдельный профиль с собственной измеренной задержкой.
|
||||
|
||||
## Условия поставки и выполнения
|
||||
|
||||
Работа идёт на существующей Ubuntu Mini через versioned artifacts с hashes/UTC/monotonic, без ручных apt/pip/служебных правок. Изменённый DG сначала становится общим компонентом, затем встраивается в обе UI. Внешний релиз vendor SDK требует отдельно разрешённых условий распространения. Полный интерфейс управления не включает неоговорённые destructive operations: delete/format/firmware/power.
|
||||
@@ -0,0 +1,91 @@
|
||||
# X4 — автономное подключение и питание
|
||||
|
||||
Срез10.09.2026. Основание — запрос владельца после восстановления NET03:
|
||||
определить последствия отключения, необходимость ручного выбора USB-режима,
|
||||
предел автоматического восстановления и возможность управления питанием портов
|
||||
существующего Ubuntu Mac mini. Это план следующей приёмки, не свидетельство
|
||||
уже выполненных replug/power tests.
|
||||
|
||||
## Что подтверждено
|
||||
|
||||
- Rover006 online через Tailscale, Node0.8.19. Модель X4 установлена0.1.3-2;
|
||||
исправление B11/0.1.3-3 входит в Node, но ещё не применено.
|
||||
- Последняя прочитанная телеметрия X4 v1.7.18: connected=true, preview=1,
|
||||
recording=0, battery level100. Это сведения камеры; свежие отображённые кадры
|
||||
и достаточность внешнего питания этим чтением не проверялись.
|
||||
- Борт — Apple Macmini6,2. X4 подключена напрямую к xHCI USB3,5000M.
|
||||
Linux предоставляет `disable` для отдельных портов и соответствие USB2/USB3
|
||||
половин физического разъёма. Наличие файла не доказывает отключение VBUS.
|
||||
- uhubctl не установлен. Непривилегированный lsusb не смог прочитать hub
|
||||
characteristics; поддержка аппаратного per-port power switching не определена.
|
||||
bMaxPower из USB descriptor не является измерением реального потребления.
|
||||
|
||||
## Поведение текущего приложения
|
||||
|
||||
Установка драйвера сохраняется после отключения. Supervisor сопоставляет USB
|
||||
с hardware identity, останавливает службу исчезнувшего экземпляра и создаёт
|
||||
новую изолированную службу при возвращении устройства. Identity определяется
|
||||
серийным идентификатором, новая SDK-сессия получает новый session_id. Все эти
|
||||
шаги уже входят в установленный модельный пакет; ручная переустановка после
|
||||
каждого подключения не является проектным поведением.
|
||||
|
||||
Однако cold start и replug на hardware ещё не приняты. Worker делает одну
|
||||
попытку SDK Open с watchdog40s; после ошибки не имеет bounded retry/reopen.
|
||||
Служба использует Restart=no. При потере ответа refresh помечает данные
|
||||
несвежими, но сам не переоткрывает SDK. Автоматического preview.start при
|
||||
создании новой сессии нет. Поэтому обещать «вынул/вставил — прежнее видео само
|
||||
вернулось» сейчас нельзя. Бортовая связь и камера имеют независимый lifecycle.
|
||||
|
||||
Preview, SD-запись и питание разделены. Неопределённый результат команды записи
|
||||
после обрыва нельзя автоматически повторять. Снятие USB при аккумуляторе не
|
||||
считается остановкой записи или физическим выключением камеры.
|
||||
|
||||
## Возможности производителя и ограничения
|
||||
|
||||
Официальная [инструкция USB-режима](https://onlinemanual.insta360.com/x4/en-us/operating-tutorials/connect/connect-app)
|
||||
указывает, что X4 запоминает последний режим, включая Control with Android.
|
||||
Ожидается однократная ручная настройка; исключение Reverse Charging отдельно
|
||||
описано производителем. Сохранение режима после холодного включения нашей
|
||||
камеры v1.7.18 предстоит подтвердить.
|
||||
|
||||
[Developer FAQ](https://onlinemanual.insta360.com/developer/en-us/resource/camera)
|
||||
подтверждает USB power-on X4, но не USB power-off, и требует стабильные5V3A
|
||||
для нормальной работы. Это не доказательство достаточности конкретного порта.
|
||||
[ShutdownCamera](https://insta360develop.github.io/Insta360-Developer_Docs/en/x/desktop/camera/)
|
||||
ограничен более новыми моделями; наличие объявления метода в общем SDK header
|
||||
не означает поддержку X4. Новый SDK также описывает auto-stop recording on
|
||||
disconnect для части прошивок; текущий bridge его не включает, совместимость
|
||||
и требуемая политика отдельно не приняты.
|
||||
|
||||
[uhubctl](https://github.com/mvp/uhubctl#faq) требует аппаратной поддержки.
|
||||
Даже заявленный power switching может отключать только данные, оставляя5V.
|
||||
Нужна физическая проверка VBUS на точном разъёме. С аккумулятором снятие внешних
|
||||
5V не даёт гарантированного полного выключения X4. Если встроенный порт не
|
||||
подходит, следующий аппаратный вариант — управляемый USB-хаб с отдельным
|
||||
питанием и доказанным индивидуальным VBUS switching. Покупка/изменение схемы
|
||||
питания ещё не выполнялись.
|
||||
|
||||
## Следующий инкремент
|
||||
|
||||
Приоритет владельца: после применения B11 поставить автономность и питание
|
||||
перед расширением настроек камеры и загрузкой файлов.
|
||||
|
||||
| Шаг | Проверка и результат |
|
||||
|---|---|
|
||||
| 1. Стабильный baseline | Штатный preview STOP при подтверждённом recording0 → model preparation B11 из Core/Node → START, повторный START, verify, viewer reopen. Сохранить timestamps и реальные кадры. |
|
||||
| 2. Повторное подключение | Контролируемое USB disconnect/reconnect при открытом наблюдении: offline камеры при online Node, прежний device_id, новая session_id, отсутствие повторной установки и ручного выбора Android. Замерить время SDK-ready/первого кадра. |
|
||||
| 3. Холодный запуск | Отдельно camera power cycle и reboot Ubuntu, с фиксацией battery presence, USB mode retention, wake-on-power, порядка запуска служб. Не объединять результаты этих сценариев. |
|
||||
| 4. Автовосстановление | По выявленным отказам добавить bounded SDK reopen/backoff/watchdog per instance. Для включённого профиля автопросмотра хранить намерение preview отдельно от SDK-сессии и восстанавливать его после подтверждения той же камеры; UI пересоздаёт WebRTC на новой сессии. SD-запись/фото не повторяются. |
|
||||
| 5. Питание разъёма | Включить адресную диагностику/действие в versioned preparation artifact. Прочитать hub capability, сопоставить USB2/USB3 пару, проверить физический VBUS off/on и сохранность остальных портов. Только после доказанного питания — сценарий без батареи и измерение потребления при запуске/preview. |
|
||||
| 6. Приёмка | Несколько успешных replug/cold-start циклов,30 минут выбранного потока, измерение задержки и восстановления; все зависимости/права/правила/откаты входят в installer, чистый OS image остаётся отдельной проверкой. |
|
||||
|
||||
Целевой профиль: после однократной подготовки включение борта приводит к
|
||||
обнаружению X4 и доступному операторскому видео без локального нажатия на
|
||||
камере. Для необслуживаемого восстановления после аппаратного зависания
|
||||
дополнительно нужен доказанный способ полного power cycle. Пока не пройдены
|
||||
шаги2–6, этот результат не объявляется достигнутым.
|
||||
|
||||
Проверки отключения, reboot и VBUS выполняются в объявленном окне наблюдения.
|
||||
В этом проходе они не запускались. Любое изменение Ubuntu сначала входит в
|
||||
версионированный артефакт; глобальный USB controller reset и широкие права на
|
||||
все хабы не используются для восстановления одной камеры.
|
||||
@@ -1,21 +1,24 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {ActivityIndicator,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {LoadingRegion,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {PointViewport} from './PointViewport';
|
||||
interface Telemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;acquisition?:string;frame?:{observed_at:string}}
|
||||
export function LiveViewport({device,layer,transport,failure}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void}){
|
||||
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
|
||||
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
|
||||
const [state,setState]=useState('Подключаем просмотр');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
||||
useEffect(()=>{
|
||||
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||
let cancelled=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();
|
||||
let cancelled=false;let terminal=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();let frameHandle:number|undefined;let decoded=0;
|
||||
const live=()=>{if(cancelled||terminal)return;last=Date.now();setState(device.playback_id?'Исходная запись':'Прямой эфир');};
|
||||
const frameSeen=()=>{live();if(!cancelled)frameHandle=video.current?.requestVideoFrameCallback(frameSeen);};
|
||||
setTelemetry({});setState('Подключаем просмотр');
|
||||
if(typeof RTCPeerConnection==='undefined'){setState('Просмотр не поддерживается этим браузером');return;}
|
||||
const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0});
|
||||
if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'});
|
||||
pc.ontrack=event=>{if(video.current)video.current.srcObject=new MediaStream([event.track]);};
|
||||
pc.onconnectionstatechange=()=>{if(!cancelled)setState(pc.connectionState==='connected'?(device.playback_id?'Исходная запись':'Прямой эфир'):pc.connectionState==='failed'?'Нет связи с камерой':'Подключаем просмотр');};
|
||||
channel.onmessage=event=>{try{const v=JSON.parse(event.data);last=Date.now();setTelemetry(v);}catch{ /* Ignore malformed preview, not control state. */ }};
|
||||
timer=setInterval(()=>{if(channel.readyState==='open')channel.send('keepalive');if(Date.now()-last>8000)setState('Нет свежих кадров');},2000);
|
||||
pc.ontrack=event=>{if(video.current){video.current.srcObject=new MediaStream([event.track]);if(typeof video.current.requestVideoFrameCallback==='function')frameHandle=video.current.requestVideoFrameCallback(frameSeen);}};
|
||||
pc.onconnectionstatechange=()=>{if(cancelled||terminal)return;if(pc.connectionState==='failed'){terminal=true;setState('Нет связи с камерой');}else if(pc.connectionState==='connected')setState(current=>current==='Прямой эфир'||current==='Исходная запись'?current:'Ожидаем изображение');};
|
||||
channel.onmessage=event=>{try{const v=JSON.parse(event.data);if(layer==='points'||layer==='motion')live();setTelemetry(v);}catch{ /* Ignore malformed preview, not control state. */ }};
|
||||
timer=setInterval(()=>{if(terminal)return;if(channel.readyState==='open')channel.send('keepalive');if(video.current&&typeof video.current.requestVideoFrameCallback!=='function')void pc.getStats().then(stats=>{stats.forEach(value=>{if(value.type==='inbound-rtp'&&value.kind==='video'&&value.framesDecoded>decoded){decoded=value.framesDecoded;live();}});}).catch(()=>undefined);if(Date.now()-last>8000)setState('Нет свежих кадров');},2000);
|
||||
async function connect(){try{
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if(pc.iceGatheringState!=='complete')await new Promise<void>((resolve,reject)=>{const t=setTimeout(()=>reject(new Error('Не удалось подготовить частный канал просмотра.')),10000);pc.addEventListener('icegatheringstatechange',()=>{if(pc.iceGatheringState==='complete'){clearTimeout(t);resolve();}});});
|
||||
@@ -23,16 +26,17 @@ export function LiveViewport({device,layer,transport,failure}:{device:Sensor;lay
|
||||
const answer=await perform<{peer_id:string;sdp:string;type:'answer'}>(transport,device,'offer',{sdp:pc.localDescription!.sdp,layer});peerID=answer.peer_id;
|
||||
if(cancelled){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);return;}
|
||||
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
|
||||
}catch(e){if(!cancelled){setState('Просмотр недоступен');failure(e);}}}
|
||||
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
|
||||
}catch(e){if(!cancelled){terminal=true;setState('Просмотр недоступен');failure(e);}}}
|
||||
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);if(frameHandle!==undefined)video.current?.cancelVideoFrameCallback(frameHandle);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
|
||||
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
|
||||
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
|
||||
const active=device.snapshot.acquisition==='streaming';
|
||||
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer)} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
||||
{!active?<p>Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.</p>:<>
|
||||
{state==='Подключаем просмотр'&&<ActivityIndicator label="Открываем частный канал камеры"/>}
|
||||
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={title??(layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer))} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
||||
{!active?<p>{inactiveMessage??'Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.'}</p>:<>
|
||||
<LoadingRegion loading={state==='Подключаем просмотр'||state==='Ожидаем изображение'} label={state}>
|
||||
{layer==='points'?<PointViewport points={telemetry.points??[]}/>:layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,v])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} className="sensor-media" autoPlay muted playsInline/>}
|
||||
<p className="sensor-note">{layer==='points'?'Предпросмотр до 2 000 точек · координаты камеры · метры':layer==='depth'?'Цветное представление глубины. Исходная глубина записывается без видеосжатия.':'Предпросмотр камеры. Исходная запись хранится на БК.'}</p>
|
||||
</LoadingRegion>
|
||||
<p className="sensor-note">{note??(layer==='points'?'Предпросмотр до 2 000 точек · координаты камеры · метры':layer==='depth'?'Цветное представление глубины. Исходная глубина записывается без видеосжатия.':'Предпросмотр камеры. Исходная запись хранится на БК.')}</p>
|
||||
</>}
|
||||
</SettingsCard></div>;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,IconButton,Select,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||
import {Button,Icon,IconButton,Select,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {LiveViewport} from './LiveViewport';
|
||||
export function SensorDetail({device,transport,back,refresh,failure,enabled=true}:{enabled?:boolean;device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
|
||||
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pending,setPending]=useState(false);const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
|
||||
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pendingAction,setPendingAction]=useState<string|null>(null);const pending=pendingAction!==null;const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
|
||||
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
|
||||
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]);
|
||||
async function act(action:string,parameters:Record<string,unknown>={}){if(pending||!enabled)return;failure(null);setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}}
|
||||
async function act(action:string,parameters:Record<string,unknown>={}){if(pending||!enabled)return;failure(null);setPendingAction(action==='start'?(parameters.record?'record.start':'preview.start'):action==='replay'?'replay:'+String(parameters.recording_id):action);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPendingAction(null);}}
|
||||
const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index)));
|
||||
const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))];
|
||||
const videoNames:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Точки',motion:'Движение'};
|
||||
@@ -16,15 +16,15 @@ export function SensorDetail({device,transport,back,refresh,failure,enabled=true
|
||||
<SettingsCard title={device.name} description={`${device.model} · USB ${device.usb}${device.firmware?' · '+device.firmware:''}`}>
|
||||
{!device.online&&!device.snapshot.message&&<p>Камера отключена от БК.</p>}
|
||||
{device.snapshot.message&&<p>{device.snapshot.message}</p>}
|
||||
<div className="sensor-actions"><Button disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={!enabled||pending||(!active&&device.snapshot.acquisition!=='failed')} onClick={()=>void act('stop')}>{device.playback_id?'Закрыть запись':'Остановить захват'}</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
|
||||
<div className="sensor-actions"><Button loading={pendingAction==='preview.start'} disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button loading={pendingAction==='record.start'} disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={!enabled||pending||(!active&&device.snapshot.acquisition!=='failed')} loading={pendingAction==='stop'} onClick={()=>void act('stop')}>{device.playback_id?'Закрыть запись':'Остановить захват'}</Button></div>
|
||||
{device.playback_id&&<p>Просмотр исходной записи · повтор. Камера для него не запускается.</p>}
|
||||
{device.snapshot.acquisition==='stopping'&&<p>Сохраняем исходную запись и проверяем её целостность.</p>}
|
||||
{device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
||||
</SettingsCard>
|
||||
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
|
||||
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={!enabled||pending||active}/>)}</div></SettingsCard>
|
||||
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/><Button disabled={!enabled||pending||currentOption.read_only||!!device.playback_id||optionValue===''} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
||||
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={!enabled||pending||active||item.state!=='complete'} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
||||
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/><Button disabled={!enabled||pending||currentOption.read_only||!!device.playback_id||optionValue===''} loading={pendingAction==='option'} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
||||
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={!enabled||pending||active||item.state!=='complete'} loading={pendingAction==='replay:'+item.id} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
||||
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {ActivityIndicator,Icon,ResourceRow,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import type {SensorInventory} from './contracts';
|
||||
import {devicePreparation} from './preparation';
|
||||
|
||||
export function SensorPreparations({inventory}:{inventory:SensorInventory|null}) {
|
||||
if(!inventory)return null;
|
||||
return <>{inventory.items.map(device=>{
|
||||
const preparation=devicePreparation(inventory,device);
|
||||
if(!preparation)return null;
|
||||
return <SettingsCard key={preparation.operation_id} title={`Подготовка: ${device.name}`}>
|
||||
{preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message}
|
||||
status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='unknown'?'Результат неизвестен':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>)}
|
||||
</SettingsCard>;
|
||||
})}</>;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import {useCallback,useEffect,useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {useCallback,useEffect,useRef,useState} from 'react';
|
||||
import {LoadingRegion,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||
import {SensorDetail} from './SensorDetail';
|
||||
import {SensorPreparations} from './SensorPreparations';
|
||||
import {devicePreparation,preparationProgress} from './preparation';
|
||||
import {sensorStatus} from './sensorStatus';
|
||||
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
|
||||
import type {RerunHostFactory} from './rerunHost';
|
||||
@@ -9,7 +11,7 @@ import './sensors.css';
|
||||
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[]}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[]}){
|
||||
const [adding,setAdding]=useState(false);
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<string|null>(null);const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlySet<string>>(()=>new Set());const activeActions=useRef(new Set<string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
|
||||
useEffect(()=>{
|
||||
@@ -21,7 +23,13 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
if(!close)unavailable();
|
||||
return()=>{active=false;close?.();if(fallback)clearInterval(fallback);};
|
||||
},[transport,enabled,refresh]);
|
||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
|
||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){
|
||||
if(activeActions.current.has(device.id))return;
|
||||
activeActions.current.add(device.id);setBusy(new Set(activeActions.current));setError('');
|
||||
try{await perform(transport,device,action,parameters);await refresh();setEditing(current=>current?.id===device.id?null:current);}
|
||||
catch(e){failure(e);}
|
||||
finally{activeActions.current.delete(device.id);setBusy(new Set(activeActions.current));}
|
||||
}
|
||||
async function completeEnrollment(sessionId:string,signal?:AbortSignal){
|
||||
if(!enabled)throw new Error('БК недоступен. Обновите состояние устройства.');
|
||||
const value=await transport.inventory();
|
||||
@@ -37,15 +45,15 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
||||
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
|
||||
{!inventory?<LoadingRegion loading label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy.has(item.id);
|
||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
|
||||
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?'Подготовка или команда выполняется':status.label;
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
||||
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</StatusBadge>}/></SettingsCard>}
|
||||
const prep=devicePreparation(inventory,item);
|
||||
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
||||
<SensorPreparations inventory={inventory}/>
|
||||
</>}
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</div>;
|
||||
|
||||
@@ -3,7 +3,8 @@ export interface SensorOption { id: string; sensor: string; label: string; value
|
||||
export interface Sensor {
|
||||
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null;acquisition_phase?:string};
|
||||
live_settings?: Record<string,unknown>;
|
||||
id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||
camera_status?: Record<string,unknown>;
|
||||
id: string; name: string; model: string; initializable?:boolean; preparation_safe?:boolean; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
||||
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
||||
frames?: Record<string,number>; last_frame?: {observed_at:string}; recording?: {id: string};
|
||||
@@ -12,6 +13,12 @@ export interface Sensor {
|
||||
export interface SensorInventory {
|
||||
fresh?: boolean; items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;requested_at:string;state:string;error?:string}[];
|
||||
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
|
||||
preparations?: SensorPreparation[];
|
||||
}
|
||||
export interface SensorPreparation {
|
||||
operation_id:string; device_id:string; model_id:string; state:string; phase:string;
|
||||
started_at:number; profile_started_at:number;
|
||||
steps:{id:string;label:string;state:string;message?:string}[];
|
||||
}
|
||||
export function enrolledDevice(inventory:SensorInventory,sessionId:string):Sensor|null {
|
||||
if(inventory.fresh===false||!sessionId)return null;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type {Sensor,SensorInventory,SensorPreparation} from './contracts';
|
||||
|
||||
export function devicePreparation(inventory:SensorInventory,device:Sensor):SensorPreparation|undefined {
|
||||
const operation=inventory.operations?.find(value=>value.device_id===device.id&&value.action_id==='prepare'&&value.state==='running');
|
||||
if(!operation)return;
|
||||
if(inventory.preparations){
|
||||
const matches=inventory.preparations.filter(value=>value.operation_id===operation.operation_id&&value.device_id===device.id);
|
||||
return matches.length===1&&matches[0].steps.length>0?matches[0]:undefined;
|
||||
}
|
||||
// Old Nodes publish a single RealSense installation report. Never project
|
||||
// that compatibility report onto an Insta360 or another vendor's device.
|
||||
const legacy=inventory.preparation;
|
||||
if(!device.id.startsWith('rsd455_')||!legacy||legacy.started_at*1000<Date.parse(operation.requested_at)-1000)return;
|
||||
return {operation_id:operation.operation_id,device_id:device.id,model_id:'realsense.d455',state:legacy.state==='error'?'error':'running',phase:legacy.state==='complete'?'verify':'profile',started_at:legacy.started_at,profile_started_at:legacy.started_at,
|
||||
steps:[...legacy.steps,{id:'verify',label:'Проверка выбранной камеры',state:legacy.state==='complete'?'running':legacy.state==='error'?'blocked':'pending'}]};
|
||||
}
|
||||
|
||||
export function preparationProgress(preparation:SensorPreparation|undefined,label:string) {
|
||||
const running=preparation?.steps.find(step=>step.state==='running');
|
||||
return {label,value:preparation&&preparation.steps.length>0?preparation.steps.filter(step=>step.state==='complete').length/preparation.steps.length:undefined,
|
||||
valueText:running?.label??label};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {Sensor} from './contracts';
|
||||
export function sensorStatus(sensor:Sensor, fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
||||
if(!fresh)return {label:'Нет свежих сведений с БК',tone:'neutral'};
|
||||
if(!sensor.online)return {label:'Не подключено',tone:'neutral'};
|
||||
if(sensor.initializable===false)return {label:'Не удалось определить камеру',tone:'warning'};
|
||||
if(sensor.snapshot.acquisition==='failed')return {label:'Ошибка захвата',tone:'danger'};
|
||||
const configured=sensor.configured??sensor.snapshot.enrollment==='enrolled';
|
||||
if(!configured)return {label:'Требуется подготовка',tone:'neutral'};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Select,SettingsCard} from '@nodedc/ui-react';
|
||||
import {type CameraSettings as Settings,resolutionLabel} from './model';
|
||||
|
||||
const PHOTO_SIZES:Record<number,string>={9:'2976 × 2976',10:'5984 × 5984',11:'11968 × 5984',12:'5952 × 2976'};
|
||||
// Pinned CameraSDK 2.1.8 PhotographyOptions_ExposureMode. A choice is shown
|
||||
// only when this camera's capability response includes the corresponding name.
|
||||
const EXPOSURE:Record<string,{id:number;label:string}>={AUTO:{id:0,label:'Автоматическая'},ISO_PRIORITY:{id:1,label:'Приоритет ISO'},SHUTTER_PRIORITY:{id:2,label:'Приоритет выдержки'},MANUAL:{id:3,label:'Ручная'},ADAPTIVE:{id:4,label:'Адаптивная'},FULL_AUTO:{id:5,label:'Полностью автоматическая'}};
|
||||
export function CameraSettings({value,disabled,apply}:{value:Settings;disabled:boolean;apply:(key:string,value:number)=>void}){
|
||||
const modeChoices=[...(value.video_modes.includes(7)?[{value:'7',label:'Видео'}]:[]),...(value.photo_modes.includes(6)?[{value:'6',label:'Фото'}]:[])];
|
||||
const exposure=(value.attributes.exposure_program?.values??[]).flatMap(name=>EXPOSURE[name]?[{value:String(EXPOSURE[name].id),label:EXPOSURE[name].label}]:[]);
|
||||
const whiteBalance=(value.attributes.white_balance?.values??[]).filter(item=>/^\d+$/.test(item)).map(item=>({value:item,label:Number(item)===0?'Автоматический':`${item} K`}));
|
||||
return <SettingsCard title="Настройки камеры"><div className="sensor-fields">
|
||||
<Select label="Режим съёмки" value={String(value.mode)} options={modeChoices} disabled={disabled} onChange={next=>apply('function_mode',Number(next))}/>
|
||||
{value.mode===7&&<Select label="Разрешение и частота записи" value={String(value.values.video_resolution)} options={value.video_resolutions.map(item=>({value:String(item.id),label:resolutionLabel(item.name)}))} disabled={disabled} onChange={next=>apply('video_resolution',Number(next))}/>}
|
||||
{value.mode===6&&<Select label="Размер фотографии" value={String(value.values.photo_size)} options={value.photo_sizes.filter(size=>PHOTO_SIZES[size]).map(size=>({value:String(size),label:PHOTO_SIZES[size]}))} disabled={disabled} onChange={next=>apply('photo_size',Number(next))}/>}
|
||||
{exposure.length>0&&<Select label="Экспозиция" value={String(value.values.exposure_mode)} options={exposure} disabled={disabled} onChange={next=>apply('exposure_mode',Number(next))}/>}
|
||||
{whiteBalance.length>0&&<Select label="Баланс белого" value={String(value.values.white_balance)} options={whiteBalance} disabled={disabled} onChange={next=>apply('white_balance',Number(next))}/>}
|
||||
</div><dl className="sensor-facts"><div><dt>ISO</dt><dd>{value.values.iso||'Авто'}</dd></div><div><dt>Выдержка</dt><dd>{value.values.shutter_seconds>0?`${value.values.shutter_seconds.toFixed(4)} с`:'Авто'}</dd></div></dl></SettingsCard>;
|
||||
}
|
||||
@@ -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<Settings|null>(null);
|
||||
const [files,setFiles]=useState<CameraFiles|null>(null);
|
||||
const [pending,setPending]=useState<string|null>(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<Settings>(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<string,unknown>={},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<unknown>(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 <div className="sensor-content"><div><Button onClick={back}>К устройствам</Button></div>
|
||||
<SettingsCard title={device.name} description={`${device.model}${device.firmware?' · '+device.firmware:''}`} actions={<IconButton label="Обновить состояние камеры" loading={pending==='refresh'} disabled={!enabled||busy} onClick={()=>void action('refresh')}><Icon name="refresh"/></IconButton>}>
|
||||
{!available&&<p>Нет свежей связи с камерой.</p>}
|
||||
<div className="sensor-actions"><Button disabled={!available||busy||status.preview!==0} loading={pending==='preview.start'} onClick={()=>void action('preview.start')}>Начать просмотр</Button><Button disabled={!available||busy||status.preview===0} loading={pending==='preview.stop'} onClick={()=>void action('preview.stop')}>Остановить просмотр</Button><Button disabled={!available||busy||status.recording!==0} loading={pending==='verify'} onClick={()=>void action('verify')}>Проверить изображение</Button></div>
|
||||
{status.alerts?.temperature_high&&<p>Камера сообщает о перегреве.</p>}
|
||||
{status.alerts?.storage_full&&<p>На карте камеры закончилось место.</p>}
|
||||
{status.alerts?.battery_low&&<p>Камера сообщает о низком заряде.</p>}
|
||||
</SettingsCard>
|
||||
<LiveViewport device={viewDevice} layer="preview" title="Изображение X4" note="Запись сохраняется на карте памяти камеры." inactiveMessage="Нажмите «Начать просмотр», чтобы получить изображение." transport={transport} failure={failure}/>
|
||||
<SettingsCard title="Съёмка" description="Фото и видеозапись сохраняются на карте памяти X4." actions={<StatusBadge tone={available&&status.recording===1?'success':status.recording===0?'neutral':'warning'}>{available?recordLabel:'Состояние недоступно'}</StatusBadge>}>
|
||||
<div className="sensor-actions"><Button disabled={!available||busy||status.recording!==0||mode!==7} loading={pending==='record.start'} onClick={()=>void action('record.start')}>Начать запись</Button><Button disabled={!available||busy||status.recording===0} loading={pending==='record.stop'} onClick={()=>void action('record.stop')}>Остановить запись</Button><Button disabled={!available||busy||status.recording!==0||mode!==6} loading={pending==='photo.capture'} onClick={()=>void action('photo.capture')}>Сделать фото</Button></div>
|
||||
{available&&status.recording===1&&<p>Запись продолжится после закрытия просмотра.</p>}
|
||||
</SettingsCard>
|
||||
<LoadingRegion loading={available&&((settingsLoading&&!settings)||pending==='settings.apply')} label={pending==='settings.apply'?'Применяем настройку':'Получаем настройки камеры'}>
|
||||
{settings?<CameraSettings value={settings} disabled={!available||busy||status.recording!==0||status.preview!==0} apply={(key,value)=>void action('settings.apply',{mode:settings.mode,key,value})}/>:available&&!settingsLoading?<p>Настройки недоступны. Обновите состояние камеры.</p>:null}
|
||||
</LoadingRegion>
|
||||
<SettingsCard title="Файлы на камере" actions={<Button disabled={!available||busy} loading={pending==='files.refresh'} onClick={()=>void action('files.list',{offset:0},'files.refresh')}>Обновить список</Button>}>
|
||||
{files?files.items.length?<><ResourceList aria-label="Файлы на карте X4">{files.items.map((item,index)=><li key={`${files.offset+index}:${item}`}><ResourceRow title={fileName(item)} icon={<Icon name="file"/>}/></li>)}</ResourceList><div className="sensor-actions"><Button disabled={!available||busy||files.offset===0} loading={pending==='files.previous'} onClick={()=>void action('files.list',{offset:Math.max(0,files.offset-32)},'files.previous')}>Назад</Button><span>{files.offset+1}–{files.offset+files.items.length} из {files.total}</span><Button disabled={!available||busy||files.offset+files.items.length>=files.total} loading={pending==='files.next'} onClick={()=>void action('files.list',{offset:files.offset+files.items.length},'files.next')}>Далее</Button></div></>:<p>Файлов нет.</p>:<p>Обновите список, чтобы увидеть записи на карте камеры.</p>}
|
||||
</SettingsCard>
|
||||
</div>;
|
||||
}
|
||||
@@ -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<string,number>;
|
||||
attributes:Record<string,{values:string[];depends_on:string[]}>;
|
||||
}
|
||||
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;}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,429 @@
|
||||
#include "bridge.h"
|
||||
|
||||
#include <camera/camera.h>
|
||||
#include <camera/device_discovery.h>
|
||||
#include <stream/stream_delegate.h>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <iomanip>
|
||||
#include <locale>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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 <typename T> bool contains(const std::vector<T>& values, T value) {
|
||||
return std::find(values.begin(), values.end(), value) != values.end();
|
||||
}
|
||||
|
||||
template <typename T> std::string numbers(const std::vector<T>& 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<std::string>& 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<uint8_t> bytes; };
|
||||
struct Frames final : StreamDelegate {
|
||||
std::mutex mutex;
|
||||
std::deque<Packet> queue;
|
||||
size_t bytes = 0;
|
||||
uint64_t generation = 0, sequence = 0;
|
||||
std::atomic<int> codec{0};
|
||||
std::atomic<bool> enabled{false};
|
||||
|
||||
void reset() {
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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<GyroData>&) override {}
|
||||
void OnExposureData(const ExposureData&) override {}
|
||||
};
|
||||
|
||||
struct Notifications {
|
||||
std::mutex mutex;
|
||||
std::atomic<int> recording{-1};
|
||||
std::atomic<uint64_t> revision{0};
|
||||
std::atomic<int> stopped_reason{-1};
|
||||
std::atomic<bool> battery_low{false}, storage_full{false}, temperature_high{false};
|
||||
};
|
||||
}
|
||||
|
||||
struct mc_x4 {
|
||||
DeviceDiscovery discovery;
|
||||
std::vector<DeviceDescriptor> descriptors;
|
||||
std::shared_ptr<Camera> camera;
|
||||
std::shared_ptr<Frames> frames = std::make_shared<Frames>();
|
||||
std::shared_ptr<Notifications> notifications = std::make_shared<Notifications>();
|
||||
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<std::mutex> 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<CameraFunctionMode>(int(value)));
|
||||
} else if (key == "video_resolution" && value == std::floor(value) && value >= 0 && value <= 4096) {
|
||||
auto resolution = static_cast<VideoResolution>(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<PhotoSize>(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<VideoResolution>(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<PhotographyOptions_ExposureMode>(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<CameraFunctionMode>(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<std::mutex> lock(notifications->mutex);
|
||||
revision = notifications->revision; notifications->recording = -1;
|
||||
}
|
||||
if (!camera->StartRecording()) return UNKNOWN;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> lock(notifications->mutex);
|
||||
revision = notifications->revision; notifications->recording = -1;
|
||||
}
|
||||
auto media = camera->StopRecording();
|
||||
if (media.Empty()) return UNKNOWN;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::string> 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<mc_x4>();
|
||||
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<Camera>(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<std::mutex> lock(n->mutex);
|
||||
n->recording = active ? 1 : 0; ++n->revision;
|
||||
});
|
||||
handle->camera->SetCaptureStoppedNotification([n](const std::string&, int reason) {
|
||||
std::lock_guard<std::mutex> 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<StreamDelegate> 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<std::mutex> 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 (...) {} }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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
|
||||
@@ -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()
|
||||
@@ -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 <camera/camera.h>
|
||||
#include <camera/device_discovery.h>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
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<ins_camera::StreamDelegate> delegate;
|
||||
ins_camera::CaptureStateCallBack capture_callback;
|
||||
std::shared_ptr<ins_camera::CaptureSettings> capture;
|
||||
std::shared_ptr<ins_camera::ExposureSettings> 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<CameraFunctionMode>(function_mode); }
|
||||
VideoEncodeType Camera::GetVideoEncodeType() const { return VideoEncodeType::H264; }
|
||||
void Camera::SetStreamDelegate(std::shared_ptr<StreamDelegate>& 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<std::string> Camera::GetCameraFilesList() const {
|
||||
std::vector<std::string> out;
|
||||
for (int i = 0; i < 65; ++i) out.push_back("/DCIM/synthetic_" + std::to_string(i) + ".insv");
|
||||
return out;
|
||||
}
|
||||
std::vector<DeviceDescriptor> DeviceDiscovery::GetAvailableDevices() {
|
||||
std::vector<DeviceDescriptor> 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<DeviceDescriptor>) { ++freed; }
|
||||
MediaUrl::MediaUrl(const std::vector<std::string>& origins, const std::vector<std::string>& proxies): uris_(origins), lrv_uris_(proxies) {}
|
||||
bool MediaUrl::Empty() const { return uris_.empty(); }
|
||||
const std::vector<std::string>& MediaUrl::OriginUrls() const { return uris_; }
|
||||
|
||||
bool Camera::SyncPhotographyOptions(CameraFunctionMode) { return connected; }
|
||||
std::shared_ptr<CaptureSettings> Camera::GetCaptureSettings(CameraFunctionMode) const { return capture; }
|
||||
std::shared_ptr<ExposureSettings> Camera::GetExposureSettings(CameraFunctionMode) const { return exposure; }
|
||||
bool Camera::SetCaptureSettings(CameraFunctionMode, std::shared_ptr<CaptureSettings> value) { ++writes; capture = value; return true; }
|
||||
bool Camera::SetExposureSettings(CameraFunctionMode, const std::shared_ptr<ExposureSettings>& 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<CameraFunctionMode> Camera::GetSupportedVideoModes() const { return {FUNCTION_MODE_NORMAL_VIDEO}; }
|
||||
std::vector<CameraFunctionMode> Camera::GetSupportedPhotoModes() const { return {FUNCTION_MODE_NORMAL_IMAGE}; }
|
||||
std::vector<PhotoSize> Camera::GetSupportedPhotoSizes(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_IMAGE ? std::vector<PhotoSize>{Size_5952_2976} : std::vector<PhotoSize>{}; }
|
||||
std::vector<VideoResolution> Camera::GetSupportedVideoResolutions(CameraFunctionMode mode) const { return mode == FUNCTION_MODE_NORMAL_VIDEO ? std::vector<VideoResolution>{RES_3840_1920P30, RES_1920_960P30} : std::vector<VideoResolution>{}; }
|
||||
std::string Camera::GetVideoResolutionName(VideoResolution value) const { return value == RES_1920_960P30 ? "1920_960_30" : "3840_1920_30"; }
|
||||
std::vector<std::string> Camera::GetSupportedAttrNames(CameraFunctionMode) const { return {"white_balance", "exposure_iso", "exposure_program"}; }
|
||||
std::vector<std::string> Camera::GetAttrDependOn(CameraFunctionMode, const std::string& name) const { return name == "exposure_iso" ? std::vector<std::string>{"record_resolution"} : std::vector<std::string>{}; }
|
||||
std::vector<std::string> 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<std::string>{"100", "6400"} : std::vector<std::string>{"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<ExposureSettingsPrivate>()) {}
|
||||
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<ins_camera::CaptureSettings>(); exposure = std::make_shared<ins_camera::ExposureSettings>();
|
||||
}
|
||||
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<uint8_t> 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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 <noreply@example.invalid>
|
||||
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)))
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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)))
|
||||
@@ -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()))
|
||||
@@ -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()))
|
||||
@@ -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)
|
||||
@@ -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("<H", data, 18)[0] != 62
|
||||
):
|
||||
raise ValueError("SDK is not Linux x86_64 ELF64")
|
||||
offset = struct.unpack_from("<Q", data, 40)[0]
|
||||
size, count = struct.unpack_from("<HH", data, 58)
|
||||
if size != 64 or not 0 < count < 4096 or offset + count * size > len(data):
|
||||
raise ValueError("Invalid ELF section table")
|
||||
sections = [struct.unpack_from("<IIQQQQIIQQ", data, offset + i * size) for i in range(count)]
|
||||
needed = []
|
||||
for section in sections:
|
||||
if section[1] != 6: # SHT_DYNAMIC
|
||||
continue
|
||||
if section[6] >= 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("<qQ", data, at)
|
||||
if tag == 1: # DT_NEEDED
|
||||
end = table.find(b"\0", value)
|
||||
if end < value:
|
||||
raise ValueError("Invalid ELF dependency name")
|
||||
needed.append(table[value:end].decode("ascii"))
|
||||
versions = sorted(
|
||||
set(x.decode() for x in re.findall(rb"(?:GLIBCXX|GLIBC|CXXABI)_[0-9.]+", data))
|
||||
)
|
||||
return {"architecture": "linux-x86_64", "needed": sorted(needed), "abi_versions": versions}
|
||||
|
||||
|
||||
def verify(folder, lock):
|
||||
for entry in entries(lock):
|
||||
path = folder / entry["path"]
|
||||
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||
raise ValueError("SDK payload cannot contain symlinks")
|
||||
verified_bytes(path.read_bytes(), entry)
|
||||
return inspect_elf(folder / "lib/libCameraSDK.so")
|
||||
|
||||
|
||||
def fetch(destination, source=None):
|
||||
lock = json.loads(LOCK.read_text())
|
||||
if destination.exists():
|
||||
return verify(destination, lock)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
stage = Path(tempfile.mkdtemp(prefix=".sdk-", dir=destination.parent))
|
||||
try:
|
||||
for entry in entries(lock):
|
||||
if source:
|
||||
path = source / entry["path"]
|
||||
if path.is_symlink():
|
||||
raise ValueError("SDK source cannot be a symlink")
|
||||
data = path.read_bytes()
|
||||
else:
|
||||
host = (
|
||||
"https://media.githubusercontent.com/media/"
|
||||
if entry["lfs"]
|
||||
else "https://raw.githubusercontent.com/"
|
||||
)
|
||||
repo = lock["repository"].removeprefix("https://github.com/")
|
||||
url = host + "/".join((repo, lock["commit"], lock["directory"], entry["path"]))
|
||||
with urllib.request.urlopen(url, timeout=45) as response:
|
||||
data = response.read(entry["bytes"] + 1)
|
||||
target = stage / entry["path"]
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(verified_bytes(data, entry))
|
||||
target.chmod(0o644)
|
||||
report = verify(stage, lock)
|
||||
stage.rename(destination)
|
||||
return report
|
||||
finally:
|
||||
if stage.exists():
|
||||
shutil.rmtree(stage)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--destination", type=Path, default=ROOT / "build/sdk")
|
||||
parser.add_argument(
|
||||
"--source", type=Path, help="Import an already acquired SDK using the same lock"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check", action="store_true", help="Verify existing payload without network access"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
result = (
|
||||
verify(args.destination, json.loads(LOCK.read_text()))
|
||||
if args.check
|
||||
else fetch(args.destination, args.source)
|
||||
)
|
||||
print(json.dumps(result, indent=2))
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
mc_x4_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
if [ ! -t 0 ]; then
|
||||
exec /usr/bin/gnome-terminal --wait --title="Mission Core · Insta360 X4" -- "$mc_x4_release_dir/install"
|
||||
fi
|
||||
printf '%s\n' 'Mission Core · Insta360 X4' 'Установка драйвера и проверка подключения SDK на этом Ubuntu-компьютере.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
set +e
|
||||
/usr/bin/sudo /usr/bin/python3 -I "$mc_x4_release_dir/install_release.py" 2>&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"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user