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.
230 lines
9.3 KiB
Python
230 lines
9.3 KiB
Python
"""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)
|