Publish Nightwing typed witness receipts
This commit is contained in:
parent
f2d212bffe
commit
2ecb36bcc9
3 changed files with 254 additions and 0 deletions
|
|
@ -55,6 +55,18 @@ Default V4L2 cadence is only 30 fps, but explicitly setting
|
|||
at 320x240 with zero sequence gaps. The witness worker must set frame interval;
|
||||
otherwise Linux will quietly look worse than it is.
|
||||
|
||||
Nightwing also has a typed witness publisher staged as
|
||||
`~/.local/bin/nightwing_typed_witness_publisher.py`. The repo source is
|
||||
`tools/nightwing_typed_witness_publisher.py`. It sends binary MessagePack
|
||||
`mimir.eve_sensor_observation.v1` records to `Mimir.EveSensorReceiver` on
|
||||
`ws://192.168.1.66:8796/eve/periwinkle`; the receiver normalizes them into the
|
||||
Mimir observation ledger, Odin projects them as `observation-stream` nodes, and
|
||||
Nightwing's Eve TUI lowers those nodes in the `CultMesh Witness Streams` panel.
|
||||
The current status records are camera-device receipts for `/dev/video*` and a
|
||||
`nightwing-leap-tracking` placeholder that is intentionally `0` until real Leap
|
||||
tracking owns that signal. Values are `[present, videoIndex, isPs3Eye]` for
|
||||
camera-device receipts.
|
||||
|
||||
## Runtime Profile
|
||||
|
||||
Use:
|
||||
|
|
|
|||
|
|
@ -277,3 +277,4 @@
|
|||
{"ts": "2026-05-31T19:21:23+01:00", "type": "implementation", "status": "ok", "note": "Added MimirMoveControllerTracker for the first stream overlay product: PS3 Eye luma observations produce bounded per-controller PS Move sphere histories keyed by controllerId and commanded RGB identity, then lower through existing Feature evidence. Added config/mimir-runtime.move-stream.local.json for Kiyo Pro program view, both PS3 Eyes, Scarlett shotgun/cardioid/Raven loopback channels, and Raven SRT display. --move-controller-overlay-smoke passes with two controller histories and one valid FieldEvidence claim/candidate."}
|
||||
{"ts": "2026-05-31T19:50:49+01:00", "type": "decision", "status": "ok", "note": "Nightwing is now the explicit Eyes/Moves witness authority: it owns local PS3 Eye USB reads, PS Move Bluetooth/HID, LED schedules, timestamps, and compact marker/feature receipts, while Starfire keeps canonical timing/global calibration/program sync and Fensalir keeps dense GPU fusion/surface/reservoir presentation. Encoded in Nightwing distributed witness/profile and verified with --perfect-machine-profile-smoke."}
|
||||
{"ts": "2026-05-31T20:29:03+01:00", "type": "verification", "status": "ok", "note": "Nightwing now hosts both PS3 Eyes. Linux enumerates them as ov534/gspca V4L2 devices on /dev/video2 and /dev/video3; tools/nightwing_v4l2_cadence_probe.py proved both deliver 320x240 at ~187 fps with zero sequence gaps after explicitly setting VIDIOC_S_PARM timeperframe=1/187. Default streaming remains 30 fps and is not a valid cadence proof."}
|
||||
{"ts": "2026-05-31T21:07:55+01:00", "type": "implementation", "status": "ok", "note": "Nightwing visible ops now drinks typed witness status through Mimir/Odin instead of a local status sidecar: tools/nightwing_typed_witness_publisher.py sends binary MessagePack mimir.eve_sensor_observation.v1 records to Mimir.EveSensorReceiver, Odin projects them as observation-stream nodes, and the Nightwing Eve TUI renders those accepted CultMesh streams. Live deployment showed fresh nightwing-video0..3 and nightwing-leap-tracking streams on the TUI."}
|
||||
|
|
|
|||
241
tools/nightwing_typed_witness_publisher.py
Normal file
241
tools/nightwing_typed_witness_publisher.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Publish Nightwing witness status as typed Mimir CultMesh observations.
|
||||
|
||||
This sends MessagePack `mimir.eve_sensor_observation.v1` documents to
|
||||
Mimir.EveSensorReceiver. It is deliberately narrow: Nightwing owns local sensor
|
||||
presence/timing receipts, while Mimir and Odin own synchronization and UI
|
||||
projection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import datetime as dt
|
||||
import glob
|
||||
import os
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
|
||||
def pack_nil() -> bytes:
|
||||
return b"\xc0"
|
||||
|
||||
|
||||
def pack_bool(value: bool) -> bytes:
|
||||
return b"\xc3" if value else b"\xc2"
|
||||
|
||||
|
||||
def pack_int(value: int) -> bytes:
|
||||
if 0 <= value <= 0x7f:
|
||||
return bytes([value])
|
||||
if -32 <= value < 0:
|
||||
return bytes([0xe0 | (value + 32)])
|
||||
if -(1 << 31) <= value < (1 << 31):
|
||||
return b"\xd2" + struct.pack(">i", value)
|
||||
return b"\xd3" + struct.pack(">q", value)
|
||||
|
||||
|
||||
def pack_float(value: float) -> bytes:
|
||||
return b"\xcb" + struct.pack(">d", float(value))
|
||||
|
||||
|
||||
def pack_str(value: str) -> bytes:
|
||||
data = value.encode("utf-8")
|
||||
if len(data) <= 31:
|
||||
return bytes([0xa0 | len(data)]) + data
|
||||
if len(data) <= 0xff:
|
||||
return b"\xd9" + bytes([len(data)]) + data
|
||||
if len(data) <= 0xffff:
|
||||
return b"\xda" + struct.pack(">H", len(data)) + data
|
||||
return b"\xdb" + struct.pack(">I", len(data)) + data
|
||||
|
||||
|
||||
def pack_array(items: list[bytes]) -> bytes:
|
||||
if len(items) <= 15:
|
||||
return bytes([0x90 | len(items)]) + b"".join(items)
|
||||
if len(items) <= 0xffff:
|
||||
return b"\xdc" + struct.pack(">H", len(items)) + b"".join(items)
|
||||
return b"\xdd" + struct.pack(">I", len(items)) + b"".join(items)
|
||||
|
||||
|
||||
def pack_double_array(values: list[float]) -> bytes:
|
||||
return pack_array([pack_float(value) for value in values])
|
||||
|
||||
|
||||
def pack_optional_string(value: str | None) -> bytes:
|
||||
return pack_nil() if value is None else pack_str(value)
|
||||
|
||||
|
||||
def pack_optional_int(value: int | None) -> bytes:
|
||||
return pack_nil() if value is None else pack_int(value)
|
||||
|
||||
|
||||
def sensor_document(
|
||||
observation_id: str,
|
||||
device_id: str,
|
||||
stream_id: str,
|
||||
kind: str,
|
||||
sequence: int,
|
||||
values: list[float],
|
||||
accuracy: int | None = None,
|
||||
) -> bytes:
|
||||
now_ns = time.time_ns()
|
||||
wall = dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
return pack_array([
|
||||
pack_str(observation_id),
|
||||
pack_str(device_id),
|
||||
pack_str(stream_id),
|
||||
pack_str(kind),
|
||||
pack_int(sequence),
|
||||
pack_int(now_ns),
|
||||
pack_int(time.monotonic_ns()),
|
||||
pack_str(wall),
|
||||
pack_str("nightwing-monotonic"),
|
||||
pack_double_array(values),
|
||||
pack_optional_string(None),
|
||||
pack_optional_int(None),
|
||||
pack_nil(),
|
||||
pack_nil(),
|
||||
pack_optional_int(accuracy),
|
||||
])
|
||||
|
||||
|
||||
class WebSocket:
|
||||
def __init__(self, url: str) -> None:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme != "ws":
|
||||
raise ValueError("Only ws:// is supported")
|
||||
self.host = parsed.hostname or "127.0.0.1"
|
||||
self.port = parsed.port or 80
|
||||
self.path = parsed.path or "/"
|
||||
self.sock: socket.socket | None = None
|
||||
|
||||
def connect(self) -> None:
|
||||
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
||||
sock = socket.create_connection((self.host, self.port), timeout=5)
|
||||
request = (
|
||||
f"GET {self.path} HTTP/1.1\r\n"
|
||||
f"Host: {self.host}:{self.port}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
sock.sendall(request.encode("ascii"))
|
||||
response = b""
|
||||
while b"\r\n\r\n" not in response:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
raise ConnectionError("WebSocket handshake closed")
|
||||
response += chunk
|
||||
if b" 101 " not in response.splitlines()[0]:
|
||||
raise ConnectionError(response.decode("utf-8", errors="replace").splitlines()[0])
|
||||
self.sock = sock
|
||||
|
||||
def send_binary(self, payload: bytes) -> None:
|
||||
self._send_frame(0x2, payload)
|
||||
|
||||
def close(self) -> None:
|
||||
if self.sock:
|
||||
self.sock.close()
|
||||
self.sock = None
|
||||
|
||||
def _send_frame(self, opcode: int, payload: bytes) -> None:
|
||||
if not self.sock:
|
||||
raise ConnectionError("WebSocket not connected")
|
||||
mask = random.randbytes(4) if hasattr(random, "randbytes") else os.urandom(4)
|
||||
length = len(payload)
|
||||
head = bytearray([0x80 | opcode])
|
||||
if length < 126:
|
||||
head.append(0x80 | length)
|
||||
elif length <= 0xffff:
|
||||
head.extend([0x80 | 126])
|
||||
head.extend(struct.pack(">H", length))
|
||||
else:
|
||||
head.extend([0x80 | 127])
|
||||
head.extend(struct.pack(">Q", length))
|
||||
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
|
||||
self.sock.sendall(bytes(head) + mask + masked)
|
||||
|
||||
|
||||
def video_devices() -> list[tuple[str, int, str]]:
|
||||
devices: list[tuple[str, int, str]] = []
|
||||
for path in sorted(glob.glob("/sys/class/video4linux/video*")):
|
||||
node = os.path.basename(path)
|
||||
try:
|
||||
index = int(node.replace("video", ""))
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(path, "name"), "r", encoding="utf-8", errors="replace") as handle:
|
||||
name = handle.read().strip()
|
||||
except OSError:
|
||||
name = "video"
|
||||
devices.append((node, index, name))
|
||||
return devices
|
||||
|
||||
|
||||
def observations(sequence: int) -> list[bytes]:
|
||||
docs: list[bytes] = []
|
||||
for node, index, name in video_devices():
|
||||
present = 1.0 if os.path.exists(f"/dev/{node}") else 0.0
|
||||
is_eye = 1.0 if "gspca" in name.lower() or "playstation" in name.lower() else 0.0
|
||||
docs.append(sensor_document(
|
||||
observation_id=f"nightwing:{node}:{sequence}",
|
||||
device_id="nightwing",
|
||||
stream_id=f"nightwing-{node}",
|
||||
kind="camera-device-status",
|
||||
sequence=sequence,
|
||||
values=[present, float(index), is_eye],
|
||||
accuracy=3 if present else 0,
|
||||
))
|
||||
docs.append(sensor_document(
|
||||
observation_id=f"nightwing:leap-tracking:{sequence}",
|
||||
device_id="nightwing",
|
||||
stream_id="nightwing-leap-tracking",
|
||||
kind="leap-tracking-status",
|
||||
sequence=sequence,
|
||||
values=[0.0],
|
||||
accuracy=0,
|
||||
))
|
||||
return docs
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
sequence = 0
|
||||
while True:
|
||||
ws = WebSocket(args.url)
|
||||
try:
|
||||
ws.connect()
|
||||
while True:
|
||||
for payload in observations(sequence):
|
||||
ws.send_binary(payload)
|
||||
sequence += 1
|
||||
if args.once:
|
||||
return
|
||||
time.sleep(max(0.02, args.interval))
|
||||
finally:
|
||||
ws.close()
|
||||
if args.once:
|
||||
return
|
||||
time.sleep(args.reconnect_seconds)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--url", default="ws://192.168.1.66:8796/eve/periwinkle")
|
||||
parser.add_argument("--interval", type=float, default=0.25)
|
||||
parser.add_argument("--reconnect-seconds", type=float, default=2.0)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args()
|
||||
run(args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in a new issue