Add packaged per-camera X4 wake and preview recovery
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -33,7 +34,9 @@ def wake_payload(target):
|
||||
ident = "instax4_" + hashlib.sha256(serial.encode()).hexdigest()[:32]
|
||||
if target["device_id"] != ident or target["wakeup_enabled"] is not True:
|
||||
raise ValueError("USB identity and enabled camera wakeup must be confirmed")
|
||||
if not re.fullmatch(r"(?:[0-9A-F]{2}:){5}[0-9A-F]{2}", target["bluetooth_address"]):
|
||||
if target["bluetooth_address"] is not None and not re.fullmatch(
|
||||
r"(?:[0-9A-F]{2}:){5}[0-9A-F]{2}", target["bluetooth_address"]
|
||||
):
|
||||
raise ValueError("Prior correlated Bluetooth address is required")
|
||||
# Company ID 0x004c is passed separately to BlueZ. X4 remote spec section1.
|
||||
return (
|
||||
@@ -189,6 +192,10 @@ def run(target, report):
|
||||
|
||||
|
||||
def main():
|
||||
def cancel(*_):
|
||||
raise RuntimeError("Wake cancelled")
|
||||
|
||||
signal.signal(signal.SIGTERM, cancel)
|
||||
report = {
|
||||
"schema": "missioncore.insta360.ble-wake/v1",
|
||||
"started": timestamp(),
|
||||
|
||||
@@ -16,7 +16,7 @@ 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-6"
|
||||
VERSION = "0.1.3-7"
|
||||
WHEELS = {
|
||||
"aiohappyeyeballs",
|
||||
"aiohttp",
|
||||
@@ -160,6 +160,7 @@ def build(output):
|
||||
"ble_diagnostic.py",
|
||||
"ble_options.py",
|
||||
"ble_wake.py",
|
||||
"wake_service.py",
|
||||
):
|
||||
files.append(
|
||||
("usr/lib/mission-core-node/insta360/" + name, (PACKAGING / name).read_bytes(), 0o644)
|
||||
|
||||
@@ -23,7 +23,7 @@ data = subprocess.check_output(["/usr/bin/dpkg-deb", "--fsys-tarfile", str(packa
|
||||
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"))
|
||||
for name in ("ble_diagnostic.py", "ble_options.py", "ble_wake.py"):
|
||||
for name in ("ble_diagnostic.py", "ble_options.py", "ble_wake.py", "wake_service.py"):
|
||||
packaged = archive.extractfile("usr/lib/mission-core-node/insta360/" + name).read()
|
||||
if packaged != (ROOT / "packaging" / name).read_bytes():
|
||||
raise ValueError("Packaged BLE module differs from the qualified source")
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
[Unit]
|
||||
Description=Mission Core bounded X4 Bluetooth wake
|
||||
After=bluetooth.service
|
||||
Wants=bluetooth.service
|
||||
ConditionPathExists=/var/lib/mission-core-insta360/active.path
|
||||
[Service]
|
||||
Type=simple
|
||||
Group=mission-core-node
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/wake_service.py
|
||||
RuntimeDirectory=mission-core-x4-recovery
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=mission-core-x4-recovery
|
||||
StateDirectoryMode=0700
|
||||
UMask=0077
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
CapabilityBoundingSet=
|
||||
TasksMax=20
|
||||
MemoryMax=128M
|
||||
CPUQuota=30%
|
||||
LimitCORE=0
|
||||
TimeoutStopSec=8
|
||||
KillMode=control-group
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -184,6 +184,7 @@ def prepare_locked():
|
||||
trusted(maintenance).unlink()
|
||||
run("/usr/bin/systemctl", "daemon-reload")
|
||||
for service in (
|
||||
"mission-core-insta360-wake.service",
|
||||
"mission-core-insta360.service",
|
||||
"mission-core-insta360-supervisor.service",
|
||||
):
|
||||
|
||||
@@ -5,6 +5,7 @@ case "$1" in
|
||||
/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-insta360-wake.service
|
||||
systemctl stop 'mission-core-x4@*.service'
|
||||
systemctl stop mission-core-insta360.service
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Fixed Bluetooth privilege boundary. Never accepts serials, paths or SDK calls."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from layout import CODE, trusted, write # noqa: E402
|
||||
from runtime.http import Server # noqa: E402
|
||||
from runtime.identity import read_binding # noqa: E402
|
||||
from runtime.lifecycle import acquisition # noqa: E402
|
||||
|
||||
ROOT = Path("/var/lib/mission-core-x4-recovery")
|
||||
SOCKET = Path("/run/mission-core-x4-recovery/driver.sock")
|
||||
IDENTIFIER = re.compile(r"instax4_[0-9a-f]{32}")
|
||||
|
||||
|
||||
def connected():
|
||||
values = []
|
||||
for path in Path("/sys/bus/usb/devices").iterdir():
|
||||
with suppress(OSError, ValueError):
|
||||
values.append(read_binding(path.name))
|
||||
return values
|
||||
|
||||
|
||||
class WakeService:
|
||||
def __init__(self, root=ROOT):
|
||||
self.root = root
|
||||
self.lock = threading.RLock()
|
||||
self.advertiser = threading.Lock()
|
||||
self.running = {}
|
||||
|
||||
def load(self, ident):
|
||||
path = self.root / (ident + ".json")
|
||||
if not path.exists():
|
||||
return {"enabled": False, "attempts": []}
|
||||
return json.loads(trusted(path).read_text())
|
||||
|
||||
def save(self, ident, value):
|
||||
write(self.root / (ident + ".json"), json.dumps(value).encode(), 0o600)
|
||||
|
||||
def control(self, ident, enabled):
|
||||
with self.lock:
|
||||
value = self.load(ident)
|
||||
if not enabled and "serial" not in value:
|
||||
return {"enabled": False}
|
||||
if enabled:
|
||||
matches = [x for x in connected() if x.device_id == ident]
|
||||
if len(matches) != 1 or not re.fullmatch(r"[A-Za-z0-9]{7,64}", matches[0].serial):
|
||||
raise ValueError("Connect the exact X4 before enabling recovery")
|
||||
if len(list(self.root.glob("instax4_*.json"))) >= 500 and "serial" not in value:
|
||||
raise ValueError("Recovery inventory is full")
|
||||
value["serial"] = matches[0].serial
|
||||
value["enabled"] = enabled
|
||||
self.save(ident, value)
|
||||
if not enabled and ident in self.running:
|
||||
self.running[ident].terminate()
|
||||
return {"enabled": enabled}
|
||||
|
||||
def wake(self, ident, attempt):
|
||||
if not self.advertiser.acquire(blocking=False):
|
||||
return {"state": "busy"}
|
||||
try:
|
||||
# Installation cannot replace code/state while a wake is in flight.
|
||||
with acquisition():
|
||||
return self.wake_locked(ident, attempt)
|
||||
finally:
|
||||
self.advertiser.release()
|
||||
|
||||
def wake_locked(self, ident, attempt):
|
||||
with self.lock:
|
||||
value = self.load(ident)
|
||||
if not value["enabled"] or "serial" not in value:
|
||||
return {"state": "disabled"}
|
||||
serial = value["serial"]
|
||||
if any(x.device_id == ident for x in connected()):
|
||||
return {"state": "connected"}
|
||||
# A radio suffix is weaker than full USB identity. Refuse known collisions.
|
||||
for path in self.root.glob("instax4_*.json"):
|
||||
other = json.loads(trusted(path).read_text()).get("serial", "")
|
||||
if other and other != serial and other[-6:] == serial[-6:]:
|
||||
return {"state": "identity_conflict"}
|
||||
now = time.time()
|
||||
attempts = [x for x in value["attempts"] if now - x["at"] < 600]
|
||||
if any(x["id"] == attempt for x in attempts):
|
||||
return {"state": "already_attempted"}
|
||||
if len(attempts) >= 3:
|
||||
return {"state": "exhausted"}
|
||||
value["attempts"] = attempts + [{"id": attempt, "at": now}]
|
||||
# Persist before advertising; a crash cannot silently replay the attempt.
|
||||
self.save(ident, value)
|
||||
target = {
|
||||
"device_id": ident,
|
||||
"serial": serial,
|
||||
"bluetooth_address": None,
|
||||
"wakeup_enabled": True,
|
||||
}
|
||||
with tempfile.TemporaryFile(dir=self.root) as output:
|
||||
process = subprocess.Popen(
|
||||
["/usr/bin/python3", "-I", str(CODE / "ble_wake.py")],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=output,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.running[ident] = process
|
||||
process.stdin.write(json.dumps(target).encode())
|
||||
process.stdin.close()
|
||||
# Release state lock during hardware wait so disable can cancel.
|
||||
self.lock.release()
|
||||
try:
|
||||
try:
|
||||
process.wait(timeout=45)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
output.seek(0)
|
||||
raw = output.read(1024 * 1024 + 1)
|
||||
finally:
|
||||
self.lock.acquire()
|
||||
self.running.pop(ident, None)
|
||||
state = "unavailable"
|
||||
try:
|
||||
if len(raw) <= 1024 * 1024:
|
||||
report = json.loads(raw)
|
||||
write(self.root / (ident + ".last-wake"), raw, 0o600)
|
||||
if process.returncode == 0 and report.get("sdk_usb_returned"):
|
||||
state = "connected"
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
return {"state": state if self.load(ident)["enabled"] else "disabled"}
|
||||
|
||||
def dispatch(self, method, route, value, _headers):
|
||||
if method == "GET" and route == "/health":
|
||||
return {"ready": True}
|
||||
if method != "POST" or not isinstance(value, dict):
|
||||
raise ValueError("Unsupported recovery request")
|
||||
ident = value.get("device_id")
|
||||
if not isinstance(ident, str) or not IDENTIFIER.fullmatch(ident):
|
||||
raise ValueError("Invalid recovery identity")
|
||||
if route == "/control" and set(value) == {"device_id", "enabled"}:
|
||||
if type(value["enabled"]) is not bool:
|
||||
raise ValueError("Recovery requires an explicit boolean")
|
||||
return self.control(ident, value["enabled"])
|
||||
if route == "/wake" and set(value) == {"device_id", "attempt"}:
|
||||
if not isinstance(value["attempt"], str) or not re.fullmatch(
|
||||
r"[0-9a-f]{32}", value["attempt"]
|
||||
):
|
||||
raise ValueError("Invalid wake attempt")
|
||||
return self.wake(ident, value["attempt"])
|
||||
raise ValueError("Unsupported recovery request")
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() or sys.argv[1:]:
|
||||
raise RuntimeError("Use the installed fixed wake service")
|
||||
trusted(ROOT, True)
|
||||
if SOCKET.exists():
|
||||
SOCKET.unlink()
|
||||
allowed = {0, pwd.getpwnam("mission-core-insta360").pw_uid}
|
||||
with Server(SOCKET, WakeService().dispatch, allowed) as server:
|
||||
SOCKET.chmod(0o660)
|
||||
server.serve_forever(poll_interval=0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user