diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38c7b10..849453b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Test run: pnpm -r --if-present run test + - name: Test traffic toolkit + run: python3 -m unittest discover -s tools/traffic/lib -p '*_test.py' + build-windows: runs-on: windows-latest diff --git a/.gitignore b/.gitignore index 28abed2..0b82c8a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ deploy/light-map.json # Traefik runtime files (generated / contains secrets) deploy/traefik/dynamic.toml deploy/traefik/acme.json + +# python bytecode from the traffic toolkit +**/__pycache__/ diff --git a/tools/traffic/PROTOCOL.md b/tools/traffic/PROTOCOL.md new file mode 100644 index 0000000..f08c7a9 --- /dev/null +++ b/tools/traffic/PROTOCOL.md @@ -0,0 +1,117 @@ +# What BEYOND and an FB4 actually say to each other + +Findings from two captures taken on the Grace Cathedral show machine +(`169.254.42.165`, hostname `WIN-R4FTH86FTV5`) with six FB4s on a link-local +network: `169.254.45.4`, `.53.5`, `.200.242`, `.210.242`, `.213.242`, `.214.242`. +One capture is idle-ish, one is a paint session. + +Everything below was read off the wire. Where the evidence stops, this document +says so — nothing here is inferred from how we imagine Pangolin works. Reproduce +it with `./bin/decode `. + +## Three protocols, not one + +| Port | Direction | Readable? | What it is | +| --- | --- | --- | --- | +| UDP 9022 | both | yes | FB4 discovery/announce, device settings, BEYOND announcing itself | +| UDP 16062 | BEYOND → network | yes, text | BEYOND's RGBA panel broadcast: what live control currently holds | +| TCP 3348 | BEYOND → FB4 | header only | the frame stream; body is encrypted | + +The important consequence: **the readable traffic tells us BEYOND's state, and +the traffic that actually drives the lasers is encrypted.** We can observe and +verify, but this is not a path to driving an FB4 ourselves. + +## UDP 16062 — live control, in plain text + +While BEYOND's RGBA panel is open (`BEYOND.ini` `[Settings] ShowRGBAPanel=1`) it +broadcasts one datagram per live-control change, CRLF-terminated ASCII: + +``` +ControlZone 3\r\n +RGBA 0, 229\r\n +``` + +The zone line is context for the value line beneath it. Channels are numbered +`0=red 1=green 2=blue 3=alpha`, and `Brightness ` is its own line. Values are +0–255. Six zones appeared, numbered 1–6. + +This is worth more than it looks. OSC is UDP and acknowledges nothing, so until +now "did BEYOND receive that?" was unanswerable. This broadcast answers it: +`./bin/rgba` watches it live, so you send a message and see the value move. +Zone numbering here is 1-based, while our OSC addresses (`/beyond/zone/{n}/…`) +are what BEYOND's OSC handler consumes — check the offset before trusting a +mapping between the two. + +## UDP 9022 — who is on the network + +Each FB4 announces itself; two shapes appear. + +A short hello carries an ASCII model tag at offset `0x20` and a device id as a +little-endian u32 right after: `FB4E`, id 566604. Those ids match the serials +printed in BEYOND's FB4 Settings list. + +Longer packets carry the 32-byte Pangolin header plus a flat array of +`(u32 tag, u32 value)` pairs — 223 of them per device here. Tags cluster by +leading nibble (`0x10xx`, `0x20xx`, …) and many values repeat as ~14006, which +reads like an index into a table BEYOND holds rather than a quantity. **No tag +has been identified.** `decode` counts them and keeps the raw values; naming them +needs controlled experiments (change one setting in BEYOND, capture, diff — which +is what `./bin/experiment` and `./bin/compare` are for), not guesswork. + +BEYOND's own announce is distinguishable by a different magic (`0d be 00 00`) +and carries the show machine's hostname as UTF-16LE at `0x28`. + +## TCP 3348 — the frame stream + +One TCP connection per FB4, opened by BEYOND from an ephemeral port. Every +message is a 32-byte plaintext header and a body: + +| offset | size | meaning | +| --- | --- | --- | +| 0 | 4 | magic `40 fb 00 00` | +| 4 | 4 | message type (LE u32) | +| 8 | 4 | total message length including this header (LE u32) | +| 12 | 4 | sequence, +1 per frame | +| 16 | 8 | clock/timestamp | +| 24 | 8 | second clock/timestamp | +| 32 | … | body | + +Types observed: + +| type | name used here | size | rate | direction | +| --- | --- | --- | --- | --- | +| `0x00030E02` | frame | 2392 B | 16–23/s per device | BEYOND → FB4 | +| `0x00010E02` | control | 80 B | 31–37/s per device | BEYOND → FB4 | +| `0x00008A0D` | telemetry | 2432 B | ~1/s | FB4 → BEYOND | +| `0x00008A0B` | unknown | 72 B | ~0.2/s | FB4 → BEYOND | + +TCP gives no message boundaries — a 2392-byte frame arrives as 1460 + 932 — so +the length field is the only framing available, which is why reading this stream +requires reassembly before parsing. + +Sequence numbers are continuous per connection (one discontinuity per capture, +at the point the capture starts mid-stream), so nothing is being dropped. + +### The body is encrypted + +Frame bodies measure ~8.00 bits/byte of entropy, do not compress, and two +consecutive frames of the same static content share almost no bytes. Point data +for 25 lasers would be highly structured and highly repetitive between frames; +this is neither. The FB4→BEYOND telemetry bodies sit lower (~6.09 bits/byte), +consistent with structure under a partly-random envelope, but they are not +readable either. + +So: **the frame path cannot be decoded from captures alone**, and this is where +passive analysis ends. Getting further would need something a capture cannot +provide (key material, instrumented software, or vendor documentation) — and +per the standing constraint on this toolkit, nothing here transmits toward the +hardware regardless. + +## What this is good for + +- Confirming an OSC message reached BEYOND, and what value it set, per zone + (`./bin/rgba`, or `./bin/decode --timeline` on a capture). +- Confirming every FB4 is present, with its id/MAC, without opening BEYOND. +- Confirming BEYOND is streaming to each FB4, at what rate, and whether frames + are being dropped — i.e. telling "the show isn't reaching the lasers" apart + from "the lasers are being told to draw nothing". diff --git a/tools/traffic/README.md b/tools/traffic/README.md index cf5950d..7fd022a 100644 --- a/tools/traffic/README.md +++ b/tools/traffic/README.md @@ -17,7 +17,7 @@ is also where you choose the directory captures are written to. - `tshark`, `dumpcap`, `capinfos`, `editcap`, `mergecap` — all ship with Wireshark. On macOS they live inside `Wireshark.app`, and the scripts look there, so a plain drag-to-Applications install works without touching `PATH`. -- `python3` (macOS and Linux both have it) for `compare`. +- `python3` (macOS and Linux both have it) for `compare`, `decode` and `rgba`. - Permission to capture. `./bin/doctor` says whether you have it and prints the exact privileged command if you do not — it never runs it for you. @@ -74,6 +74,35 @@ request/reply), and hex dumps of the first payloads. ./bin/extract captures/big.pcapng --port 7765 ``` +## Decoding + +`analyze` treats a capture as bytes; `decode` treats it as Pangolin: + +``` +./bin/decode captures/20250101-120000-output-on.pcapng +./bin/decode captures/… --timeline --hex 16 +``` + +It lists the FB4s that announced themselves (id, MAC, model tag), what BEYOND's +live control held per zone, and the framing/type mix/rate of the frame stream to +each FB4. `--timeline` prints every live-control change with its timestamp, which +is how you line a capture up against what you were doing at the time. + +For watching that live instead of after the fact: + +``` +./bin/rgba # every live-control change BEYOND broadcasts, as it happens +./bin/rgba --zone 3 +``` + +This is the only confirmation OSC can give you: BEYOND broadcasts what its live +control holds (while its RGBA panel is open), so send a message and watch the +value move. Silence means nothing is arriving. Receive-only, like everything here. + +[PROTOCOL.md](PROTOCOL.md) is what the captures so far actually say — the +readable UDP protocols, the frame framing, and the evidence that the frame +bodies are encrypted. + ## Controlled experiments ``` @@ -127,8 +156,18 @@ bin/analyze summarise a capture bin/extract cut a capture down to a host/port/filter bin/experiment guided one-state-per-file capture run bin/compare byte-level diff of two captures +bin/decode read a capture as Pangolin protocols +bin/rgba live view of BEYOND's live-control values lib/common.sh tool discovery, capture directory, JSON helpers lib/compare.py the diff itself +lib/pangolin.py the protocol decoder +lib/rgba_listen.py the live listener +``` + +The decoder has tests, run from the repo root: + +``` +python3 -m unittest discover -s tools/traffic/lib -p '*_test.py' ``` Captures are git-ignored: they contain your network's traffic, and they are big. diff --git a/tools/traffic/bin/decode b/tools/traffic/bin/decode new file mode 100755 index 0000000..411d16d --- /dev/null +++ b/tools/traffic/bin/decode @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# +# decode — read a capture as Pangolin protocols instead of as bytes. +# +# Usage: ./bin/decode [--hex N] +# +# Prints the FB4s that announced themselves (UDP 9022), what BEYOND says its +# live control holds per zone (UDP 16062), and the framing/rate of the frame +# stream to each FB4 (TCP 3348). Passive: it only reads the file. + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh" + +TSHARK="$(need_tool tshark)" +command -v python3 >/dev/null || { echo 'error: python3 is required for decode' >&2; exit 127; } + +exec python3 "$TRAFFIC_ROOT/lib/pangolin.py" --tshark "$TSHARK" "$@" diff --git a/tools/traffic/bin/rgba b/tools/traffic/bin/rgba new file mode 100755 index 0000000..0a808ac --- /dev/null +++ b/tools/traffic/bin/rgba @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# +# rgba — live view of what BEYOND's live control is holding, per zone. +# +# Usage: ./bin/rgba [--zone N] [--port 16062] [--raw] +# +# BEYOND broadcasts every live-control change while its RGBA panel is open, so +# this is the confirmation OSC itself can't give you: send a message, watch the +# value change here. Silence means nothing is reaching BEYOND. Receive-only. + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh" + +command -v python3 >/dev/null || { echo 'error: python3 is required for rgba' >&2; exit 127; } + +exec python3 "$TRAFFIC_ROOT/lib/rgba_listen.py" "$@" diff --git a/tools/traffic/lib/pangolin.py b/tools/traffic/lib/pangolin.py new file mode 100644 index 0000000..3183047 --- /dev/null +++ b/tools/traffic/lib/pangolin.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Decode the Pangolin protocols found in a capture. + +Three separate things share a network in a BEYOND install, and only two of them +are readable: + + UDP 9022 FB4 discovery/announce and device settings. Plaintext, and the + only place a device's id and firmware settings appear on the wire + without asking BEYOND. + UDP 16062 BEYOND's RGBA panel broadcast: a text line per live-control change + ("ControlZone 3", "RGBA 0, 229", "Brightness 97"). This is BEYOND + telling the network what its live control *currently holds*, which + makes it the only confirmation that an OSC message we sent landed. + TCP 3348 the frame stream, BEYOND → FB4. A 32-byte plaintext header (magic, + type, length, sequence, two clocks) wrapping an opaque body: byte + entropy ~8.0 and incompressible, i.e. encrypted. The bodies are not + decodable from a capture, so this decoder reports the framing and + the rate, not the points. + +Read-only: this parses files, it never transmits. +""" + +from __future__ import annotations + +import argparse +import math +import subprocess +import sys +from collections import Counter, defaultdict +from dataclasses import dataclass, field + +FB4_DISCOVERY_PORT = 9022 +BEYOND_RGBA_PORT = 16062 +FB4_STREAM_PORT = 3348 + +# Every FB4-side message starts with this, on both the UDP and TCP sides. +PANGOLIN_MAGIC = bytes.fromhex('40fb0000') +# BEYOND's own discovery broadcast (host → network) uses a different one. +BEYOND_MAGIC = bytes.fromhex('0dbe0000') + +HEADER_LEN = 32 +# Frame-stream message types seen so far, as the little-endian u32 at [4:8]. +STREAM_TYPE_CONTROL = 0x00010E02 +STREAM_TYPE_FRAME = 0x00030E02 +STREAM_TYPE_TELEMETRY = 0x00008A0D + +# The RGBA panel numbers its channels; BEYOND's own OSC uses names. +RGBA_CHANNELS = {0: 'red', 1: 'green', 2: 'blue', 3: 'alpha'} + + +def u32(b: bytes, off: int) -> int: + return int.from_bytes(b[off:off + 4], 'little') + + +def u64(b: bytes, off: int) -> int: + return int.from_bytes(b[off:off + 8], 'little') + + +def entropy(b: bytes) -> float: + """Bits per byte. ~8.0 means encrypted or already compressed.""" + if not b: + return 0.0 + counts = Counter(b) + return -sum(c / len(b) * math.log2(c / len(b)) for c in counts.values()) + + +@dataclass +class Header: + """The 32 bytes in front of every message on TCP 3348 and most on UDP 9022.""" + + kind: int + length: int + sequence: int + clock_a: int + clock_b: int + + @classmethod + def parse(cls, b: bytes) -> Header | None: + if len(b) < HEADER_LEN or b[0:4] != PANGOLIN_MAGIC: + return None + return cls(u32(b, 4), u32(b, 8), u32(b, 12), u64(b, 16), u64(b, 24)) + + +def split_messages(stream: bytes) -> list[bytes]: + """Cut a reassembled TCP stream into messages using the header's length. + + TCP gives us no message boundaries — a 2392-byte frame arrives as 1460 + 932 + — so the length field is the only framing available. + """ + out: list[bytes] = [] + at = 0 + while at + HEADER_LEN <= len(stream): + header = Header.parse(stream[at:at + HEADER_LEN]) + if header is None or header.length < HEADER_LEN or at + header.length > len(stream): + break + out.append(stream[at:at + header.length]) + at += header.length + return out + + +@dataclass +class Device: + """One FB4, as it describes itself on UDP 9022.""" + + ip: str + mac: str = '' + tag: str = '' + device_id: int = 0 + settings: dict[int, int] = field(default_factory=dict) + + +def parse_announce(payload: bytes) -> tuple[str, int] | None: + """The short hello: an ASCII model tag then the device id. + + e.g. `... 46 42 34 45 4c a5 08 00` → tag `FB4E`, id 566604. The tag's exact + width is unconfirmed (only one model has been observed), so it is reported + verbatim rather than interpreted. + """ + if len(payload) < 0x28: + return None + tag = payload[0x20:0x24].decode('ascii', 'replace') + if not tag.startswith('FB4'): + return None + return tag, u32(payload, 0x24) + + +def parse_settings(payload: bytes) -> dict[int, int]: + """The long announce bodies are flat (u32 tag, u32 value) pairs. + + Tags group by leading nibble (0x10xx, 0x20xx, …) and many values repeat as + ~14006, which reads like an id into a table BEYOND holds rather than a + quantity — so values are reported raw, unnamed. + """ + body = payload[HEADER_LEN:] + settings: dict[int, int] = {} + for at in range(0, len(body) - 7, 8): + tag = u32(body, at) + if tag == 0 and u32(body, at + 4) == 0: + continue + settings[tag] = u32(body, at + 4) + return settings + + +@dataclass +class ZoneState: + """What BEYOND's live control holds for one zone, per its own broadcast.""" + + updates: int = 0 + values: dict[str, int] = field(default_factory=dict) + + +def parse_rgba_panel(payload: bytes) -> list[tuple[str, str, int]]: + """`ControlZone 3\\r\\nRGBA 0, 229\\r\\n` → [('3', 'red', 229)]. + + A datagram carries the zone it applies to followed by one value, so the zone + line is context for the line under it. + """ + text = payload.decode('ascii', 'replace') + zone = '' + out: list[tuple[str, str, int]] = [] + for line in text.replace('\r\n', '\n').split('\n'): + parts = line.split() + if not parts: + continue + if parts[0] == 'ControlZone' and len(parts) > 1: + zone = parts[1] + elif parts[0] == 'RGBA' and len(parts) > 2: + channel = RGBA_CHANNELS.get(int(parts[1].rstrip(',')), parts[1].rstrip(',')) + out.append((zone, str(channel), int(parts[2]))) + elif parts[0] == 'Brightness' and len(parts) > 1: + out.append((zone, 'brightness', int(parts[1]))) + return out + + +FIELDS = [ + 'frame.time_relative', 'ip.src', 'ip.dst', 'eth.src', + 'udp.srcport', 'udp.dstport', 'tcp.srcport', 'tcp.dstport', 'tcp.stream', + 'udp.payload', 'tcp.payload', +] + + +@dataclass +class Packet: + time: float + src: str + dst: str + eth_src: str + proto: str + sport: int + dport: int + stream: str + payload: bytes + + +def read_packets(tshark: str, path: str) -> list[Packet]: + args = [tshark, '-r', path, '-n', '-T', 'fields', '-E', 'separator=|', '-E', 'occurrence=f'] + for f in FIELDS: + args += ['-e', f] + proc = subprocess.run(args, capture_output=True, text=True, check=False) + if proc.returncode != 0 and not proc.stdout: + sys.exit(f'tshark failed on {path}: {proc.stderr.strip()}') + + packets: list[Packet] = [] + for line in proc.stdout.splitlines(): + cols = line.split('|') + if len(cols) < len(FIELDS): + continue + (time, src, dst, eth, usp, udp_, tsp, tdp, stream, upay, tpay) = cols[:11] + if upay: + proto, sport, dport, raw = 'udp', usp, udp_, upay + elif tpay: + proto, sport, dport, raw = 'tcp', tsp, tdp, tpay + else: + continue + try: + payload = bytes.fromhex(raw.replace(':', '')) + except ValueError: + continue + packets.append(Packet(float(time or 0), src, dst, eth, proto, + int(sport or 0), int(dport or 0), stream, payload)) + return packets + + +def report_devices(packets: list[Packet]) -> None: + devices: dict[str, Device] = {} + host_names: set[str] = set() + for p in packets: + if p.proto != 'udp' or FB4_DISCOVERY_PORT not in (p.sport, p.dport): + continue + if p.payload[0:4] == BEYOND_MAGIC: + # BEYOND announcing itself, with the show machine's hostname as UTF-16. + name = p.payload[0x28:0x78].decode('utf-16-le', 'replace').split('\x00')[0] + if name: + host_names.add(f'{name} ({p.src})') + continue + device = devices.setdefault(p.src, Device(ip=p.src)) + device.mac = device.mac or p.eth_src + hello = parse_announce(p.payload) + if hello: + device.tag, device.device_id = hello + elif Header.parse(p.payload) and len(p.payload) > 200: + device.settings.update(parse_settings(p.payload)) + device.device_id = device.device_id or u32(p.payload, 0x10) + + print('== devices (UDP 9022) ==') + if host_names: + print(f' BEYOND host: {", ".join(sorted(host_names))}') + for ip, d in sorted(devices.items()): + label = f'{d.tag} ' if d.tag else '' + print(f' {ip:<16} {d.mac:<18} {label}id={d.device_id or "?"} ' + f'settings={len(d.settings)}') + print() + + +def report_rgba(packets: list[Packet], timeline: bool) -> None: + zones: dict[str, ZoneState] = {} + first = last = None + events: list[tuple[float, str, str, int]] = [] + for p in packets: + if p.proto != 'udp' or p.dport != BEYOND_RGBA_PORT: + continue + for zone, key, value in parse_rgba_panel(p.payload): + state = zones.setdefault(zone, ZoneState()) + state.updates += 1 + state.values[key] = value + events.append((p.time, zone, key, value)) + first = p.time if first is None else first + last = p.time + print('== BEYOND live control, per its own broadcast (UDP 16062) ==') + if not zones: + print(' nothing — BEYOND only broadcasts this with its RGBA panel open') + print(' (BEYOND.ini [Settings] ShowRGBAPanel=1)\n') + return + print(f' {len(zones)} zones, {sum(z.updates for z in zones.values())} updates ' + f'over {(last or 0) - (first or 0):.1f}s') + for zone in sorted(zones, key=lambda z: int(z) if z.isdigit() else 0): + state = zones[zone] + v = state.values + shown = ' '.join(f'{k}={v[k]}' for k in ('red', 'green', 'blue', 'alpha', 'brightness') + if k in v) + print(f' zone {zone:<3} {state.updates:>4} updates, last {shown}') + if timeline: + print(' timeline:') + for time, zone, key, value in events: + print(f' {time:8.3f} zone {zone:<3} {key:<10} {value}') + print() + + +def report_stream(packets: list[Packet], hex_bytes: int) -> None: + streams: dict[str, list[Packet]] = defaultdict(list) + for p in packets: + if p.proto == 'tcp' and FB4_STREAM_PORT in (p.sport, p.dport): + streams[f'{p.src}:{p.sport}->{p.dst}:{p.dport}'].append(p) + + print('== frame stream (TCP 3348) ==') + if not streams: + print(' none in this capture\n') + return + for key, ps in sorted(streams.items()): + # One direction of one connection, in capture order. Loss or reordering + # would desync the framing, which shows up as leftover bytes below. + stream = b''.join(p.payload for p in ps) + messages = split_messages(stream) + if not messages: + continue + span = ps[-1].time - ps[0].time + kinds = Counter(u32(m, 4) for m in messages) + print(f' {key}') + for kind, count in kinds.most_common(): + sample = next(m for m in messages if u32(m, 4) == kind) + name = {STREAM_TYPE_FRAME: 'frame', STREAM_TYPE_CONTROL: 'control', + STREAM_TYPE_TELEMETRY: 'telemetry'}.get(kind, 'unknown') + body = b''.join(m[HEADER_LEN:] for m in messages if u32(m, 4) == kind) + rate = f'{count / span:.1f}/s' if span > 0 else '—' + print(f' type 0x{kind:08x} {name:<9} {count:>5} msgs {len(sample)}B ' + f'{rate:<8} body entropy {entropy(body):.2f} bits/byte') + seqs = [Header.parse(m).sequence for m in messages if u32(m, 4) == STREAM_TYPE_FRAME] + if len(seqs) > 1: + gaps = sum(1 for a, b in zip(seqs, seqs[1:]) if b != a + 1) + print(f' frame sequence {seqs[0]}..{seqs[-1]}, {gaps} discontinuities') + leftover = len(stream) - sum(len(m) for m in messages) + if leftover: + print(f' {leftover}B unframed at the end — capture cut mid-message, ' + f'or packets were lost/reordered') + if hex_bytes: + head = messages[0][:HEADER_LEN + hex_bytes] + print(f' first message: {head.hex(" ")}') + print(' bodies at ~8.0 bits/byte and incompressible: encrypted, not parseable here') + print() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) + parser.add_argument('capture') + parser.add_argument('--tshark', default='tshark') + parser.add_argument('--hex', type=int, default=0, + help='also print this many body bytes of the first stream message') + parser.add_argument('--timeline', action='store_true', + help='print every live-control change, to line up with what you did') + args = parser.parse_args() + + packets = read_packets(args.tshark, args.capture) + print(f'{len(packets)} packets with payload in {args.capture}\n') + report_devices(packets) + report_rgba(packets, args.timeline) + report_stream(packets, args.hex) + + +if __name__ == '__main__': + main() diff --git a/tools/traffic/lib/pangolin_test.py b/tools/traffic/lib/pangolin_test.py new file mode 100644 index 0000000..07fd997 --- /dev/null +++ b/tools/traffic/lib/pangolin_test.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Tests for the Pangolin decoder: python3 -m unittest discover tools/traffic/lib -p '*_test.py' + +The fixtures here are byte-for-byte excerpts of real BEYOND ⇄ FB4 captures, so a +change that breaks framing or the live-control parse fails here rather than +silently mis-reporting a capture. +""" + +from __future__ import annotations + +import io +import unittest +from contextlib import redirect_stdout + +from pangolin import ( + BEYOND_RGBA_PORT, + FB4_DISCOVERY_PORT, + FB4_STREAM_PORT, + HEADER_LEN, + PANGOLIN_MAGIC, + STREAM_TYPE_CONTROL, + STREAM_TYPE_FRAME, + Header, + Packet, + entropy, + parse_announce, + parse_rgba_panel, + parse_settings, + report_devices, + report_rgba, + report_stream, + split_messages, +) + + +def message(kind: int, body: bytes, sequence: int = 0) -> bytes: + """Build a framed message the way BEYOND does, for framing tests.""" + header = (PANGOLIN_MAGIC + + kind.to_bytes(4, 'little') + + (HEADER_LEN + len(body)).to_bytes(4, 'little') + + sequence.to_bytes(4, 'little') + + bytes(16)) + return header + body + + +def udp(payload: bytes, *, sport: int, dport: int, src: str = '169.254.45.4', + time: float = 0.0, eth: str = '00:16:42:fb:04:2c') -> Packet: + return Packet(time, src, '169.254.42.165', eth, 'udp', sport, dport, '', payload) + + +def tcp(payload: bytes, *, time: float = 0.0) -> Packet: + return Packet(time, '169.254.42.165', '169.254.45.4', '', 'tcp', + 64463, FB4_STREAM_PORT, '0', payload) + + +class HeaderTest(unittest.TestCase): + def test_parses_a_real_frame_header(self): + # First 32 bytes of a frame BEYOND sent to 169.254.53.5. + raw = bytes.fromhex('40fb0000020e0300580900005c6c0600' + '9993772cde310000fc377c2ee2310000') + header = Header.parse(raw) + self.assertIsNotNone(header) + self.assertEqual(header.kind, STREAM_TYPE_FRAME) + self.assertEqual(header.length, 2392) + self.assertEqual(header.sequence, 420956) + + def test_rejects_foreign_bytes(self): + self.assertIsNone(Header.parse(b'GET / HTTP/1.1\r\n' + bytes(32))) + + def test_rejects_a_truncated_header(self): + self.assertIsNone(Header.parse(PANGOLIN_MAGIC + bytes(4))) + + +class SplitMessagesTest(unittest.TestCase): + def test_splits_regardless_of_tcp_segmentation(self): + stream = message(STREAM_TYPE_FRAME, bytes(2360), 1) + \ + message(STREAM_TYPE_CONTROL, bytes(48), 2) + self.assertEqual([len(m) for m in split_messages(stream)], [2392, 80]) + + def test_stops_at_a_message_the_capture_cut_in_half(self): + stream = message(STREAM_TYPE_CONTROL, bytes(48)) + \ + message(STREAM_TYPE_FRAME, bytes(2360))[:100] + messages = split_messages(stream) + self.assertEqual(len(messages), 1) + self.assertEqual(len(messages[0]), 80) + + def test_refuses_to_loop_on_a_nonsense_length(self): + stream = PANGOLIN_MAGIC + bytes(4) + (4).to_bytes(4, 'little') + bytes(20) + self.assertEqual(split_messages(stream), []) + + def test_no_messages_in_unframed_bytes(self): + self.assertEqual(split_messages(b'hello there'), []) + + +class RgbaPanelTest(unittest.TestCase): + def test_names_the_channel_numbers(self): + self.assertEqual( + parse_rgba_panel(b'ControlZone 3\r\nRGBA 0, 229\r\n'), + [('3', 'red', 229)]) + self.assertEqual( + parse_rgba_panel(b'ControlZone 6\r\nRGBA 3, 255\r\n'), + [('6', 'alpha', 255)]) + + def test_brightness_is_its_own_value(self): + self.assertEqual( + parse_rgba_panel(b'ControlZone 1\r\nBrightness 97\r\n'), + [('1', 'brightness', 97)]) + + def test_keeps_an_unknown_channel_number_verbatim(self): + self.assertEqual( + parse_rgba_panel(b'ControlZone 2\r\nRGBA 9, 12\r\n'), + [('2', '9', 12)]) + + def test_several_values_share_the_zone_above_them(self): + payload = b'ControlZone 4\r\nRGBA 0, 1\r\nRGBA 1, 2\r\nBrightness 3\r\n' + self.assertEqual(parse_rgba_panel(payload), + [('4', 'red', 1), ('4', 'green', 2), ('4', 'brightness', 3)]) + + def test_ignores_traffic_that_is_not_the_panel(self): + self.assertEqual(parse_rgba_panel(b'\x00\x01\x02\x03'), []) + + +class AnnounceTest(unittest.TestCase): + def test_reads_the_model_tag_and_device_id(self): + payload = bytes(0x20) + b'FB4E' + (566604).to_bytes(4, 'little') + self.assertEqual(parse_announce(payload), ('FB4E', 566604)) + + def test_ignores_a_packet_without_a_model_tag(self): + self.assertIsNone(parse_announce(bytes(0x20) + b'....' + bytes(4))) + + def test_ignores_a_short_packet(self): + self.assertIsNone(parse_announce(bytes(8))) + + +class SettingsTest(unittest.TestCase): + def test_reads_tag_value_pairs_and_skips_padding(self): + body = ((0x1001).to_bytes(4, 'little') + (14006).to_bytes(4, 'little') + + bytes(8) + + (0x2003).to_bytes(4, 'little') + (7).to_bytes(4, 'little')) + self.assertEqual(parse_settings(bytes(HEADER_LEN) + body), + {0x1001: 14006, 0x2003: 7}) + + def test_no_body_no_settings(self): + self.assertEqual(parse_settings(bytes(HEADER_LEN)), {}) + + +class EntropyTest(unittest.TestCase): + def test_one_repeated_byte_carries_nothing(self): + self.assertEqual(entropy(b'\x00' * 64), 0.0) + + def test_every_byte_value_once_is_eight_bits(self): + self.assertEqual(entropy(bytes(range(256))), 8.0) + + def test_empty(self): + self.assertEqual(entropy(b''), 0.0) + + +class ReportTest(unittest.TestCase): + """The reports must say 'nothing here' rather than crash on a quiet capture.""" + + def render(self, fn, *args) -> str: + out = io.StringIO() + with redirect_stdout(out): + fn(*args) + return out.getvalue() + + def test_empty_capture_reports_each_protocol_as_absent(self): + self.assertIn('devices', self.render(report_devices, [])) + self.assertIn('RGBA panel', self.render(report_rgba, [], False)) + self.assertIn('none in this capture', self.render(report_stream, [], 0)) + + def test_devices_are_listed_with_mac_and_id(self): + payload = bytes(0x20) + b'FB4E' + (566604).to_bytes(4, 'little') + text = self.render(report_devices, [ + udp(payload, sport=FB4_DISCOVERY_PORT, dport=FB4_DISCOVERY_PORT)]) + self.assertIn('169.254.45.4', text) + self.assertIn('00:16:42:fb:04:2c', text) + self.assertIn('id=566604', text) + + def test_zone_state_is_the_last_value_seen(self): + packets = [ + udp(b'ControlZone 2\r\nRGBA 0, 10\r\n', sport=5000, + dport=BEYOND_RGBA_PORT, time=0.0), + udp(b'ControlZone 2\r\nRGBA 0, 200\r\n', sport=5000, + dport=BEYOND_RGBA_PORT, time=1.0), + ] + text = self.render(report_rgba, packets, False) + self.assertIn('red=200', text) + self.assertIn('2 updates', text) + + def test_timeline_prints_every_change(self): + packets = [ + udp(b'ControlZone 2\r\nRGBA 0, 10\r\n', sport=5000, + dport=BEYOND_RGBA_PORT, time=0.0), + udp(b'ControlZone 2\r\nBrightness 55\r\n', sport=5000, + dport=BEYOND_RGBA_PORT, time=0.5), + ] + text = self.render(report_rgba, packets, True) + self.assertIn('timeline', text) + self.assertIn('brightness 55', text) + + def test_stream_report_counts_types_and_flags_the_opaque_body(self): + frames = message(STREAM_TYPE_FRAME, bytes(range(256)) * 9 + bytes(56), 1) + \ + message(STREAM_TYPE_FRAME, bytes(range(256)) * 9 + bytes(56), 2) + text = self.render(report_stream, [tcp(frames, time=0.0), + tcp(b'', time=1.0)], 0) + self.assertIn('frame', text) + self.assertIn('2 msgs', text) + self.assertIn('encrypted', text) + + def test_stream_report_notices_a_gap_in_the_frame_sequence(self): + stream = message(STREAM_TYPE_FRAME, bytes(16), 1) + \ + message(STREAM_TYPE_FRAME, bytes(16), 9) + text = self.render(report_stream, [tcp(stream)], 0) + self.assertIn('1..9, 1 discontinuities', text) + + def test_stream_report_reports_bytes_it_could_not_frame(self): + stream = message(STREAM_TYPE_FRAME, bytes(16), 1) + b'\x40\xfb\x00\x00' + text = self.render(report_stream, [tcp(stream)], 0) + self.assertIn('4B unframed', text) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/traffic/lib/rgba_listen.py b/tools/traffic/lib/rgba_listen.py new file mode 100644 index 0000000..10642eb --- /dev/null +++ b/tools/traffic/lib/rgba_listen.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Watch BEYOND's live-control values as it broadcasts them. + +With its RGBA panel open, BEYOND broadcasts every live-control change as text on +UDP 16062 (`ControlZone 3` / `RGBA 0, 229` / `Brightness 97`). Since OSC is UDP +and never acknowledges anything, this broadcast is the only way to see whether a +message we sent actually reached BEYOND's live control — and what it did with it. + +Passive: binds and receives, never sends. +""" + +from __future__ import annotations + +import argparse +import socket +import sys +import time + +sys.path.insert(0, __file__.rsplit('/', 1)[0]) + +from pangolin import BEYOND_RGBA_PORT, parse_rgba_panel # noqa: E402 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) + parser.add_argument('--port', type=int, default=BEYOND_RGBA_PORT) + parser.add_argument('--zone', help='only print this zone') + parser.add_argument('--raw', action='store_true', help='print the datagrams verbatim') + args = parser.parse_args() + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(('', args.port)) + except OSError as err: + sys.exit(f'cannot listen on {args.port}: {err}') + + # Line-buffered throughout: this is watched live and often piped to a log. + print(f'listening on udp/{args.port} — BEYOND broadcasts here only while its ' + f'RGBA panel is open (BEYOND.ini ShowRGBAPanel=1)', flush=True) + print('nothing below means nothing is reaching BEYOND\'s live control\n', flush=True) + started = time.monotonic() + while True: + data, addr = sock.recvfrom(2048) + stamp = time.monotonic() - started + if args.raw: + print(f'{stamp:8.3f} {addr[0]} {data!r}', flush=True) + continue + for zone, key, value in parse_rgba_panel(data): + if args.zone and zone != args.zone: + continue + print(f'{stamp:8.3f} {addr[0]:<15} zone {zone:<3} {key:<10} {value}', flush=True) + + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print()