Files
NODEDC_MISSION_CORE/plugins/insta360-x4/packaging/prepare.py
T
DCCONSTRUCTIONS a3c15e11e9 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.
2026-09-10 09:21:24 +03:00

232 lines
8.8 KiB
Python

"""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)