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.
232 lines
8.8 KiB
Python
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)
|