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.
231 lines
8.1 KiB
Python
231 lines
8.1 KiB
Python
"""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)))
|