From 39b8f00570a33f5b0752fc1876d822d0bbf3f6e7 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 19 Aug 2026 08:41:17 +0000 Subject: [PATCH] feat(traffic): guided experiments, a replay test for the plaintext live-control lines, and the frame-repeat answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frame bodies never repeat: 1,161 static-content frames across two captures give 1,161 distinct bodies, no byte constant across frames, and nothing shared between devices getting the same scene — so a captured frame cannot be replayed and known-plaintext has nothing to bite on. 'decode --repeats' reproduces it. bin/session runs the open questions from PROTOCOL.md end to end (capture, guide the operator, stop, decode, interpret). bin/replay sends the plaintext lines BEYOND broadcasts on 16062, to find out whether anything acts on them; dry-run unless given --transmit and a --host. --- README.md | 2 +- packages/cli/README.md | 4 +- tools/traffic/PROTOCOL.md | 80 +++++++++++- tools/traffic/README.md | 49 +++++++- tools/traffic/bin/replay | 21 ++++ tools/traffic/bin/session | 188 +++++++++++++++++++++++++++++ tools/traffic/lib/pangolin.py | 102 ++++++++++++++++ tools/traffic/lib/pangolin_test.py | 46 +++++++ tools/traffic/lib/replay.py | 187 ++++++++++++++++++++++++++++ tools/traffic/lib/replay_test.py | 82 +++++++++++++ 10 files changed, 750 insertions(+), 11 deletions(-) create mode 100755 tools/traffic/bin/replay create mode 100755 tools/traffic/bin/session create mode 100644 tools/traffic/lib/replay.py create mode 100644 tools/traffic/lib/replay_test.py diff --git a/README.md b/README.md index 3cd82e3..207abda 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ pnpm build | Tool | Description | |------|-------------| -| `tools/traffic` | Passive capture and byte-level analysis of Pangolin BEYOND ⇄ FB4 traffic, on Wireshark's CLI. Driven from Advanced → Traffic in the desktop app, or straight from a terminal — see [tools/traffic/README.md](tools/traffic/README.md). Observation only; it never transmits to laser hardware. | +| `tools/traffic` | Passive capture and byte-level analysis of Pangolin BEYOND ⇄ FB4 traffic, on Wireshark's CLI. Driven from Advanced → Traffic in the desktop app, or straight from a terminal — see [tools/traffic/README.md](tools/traffic/README.md). Observation, plus one hand-run experiment (`bin/replay`) that sends BEYOND's own plaintext live-control lines. | ## Architecture diff --git a/packages/cli/README.md b/packages/cli/README.md index 08946ca..89900ee 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -121,8 +121,8 @@ osc` defaults to 8000, BEYOND's factory port) and the zone has to be under live control. This is aimed output on a configured target, unrelated to -[`tools/traffic`](../../tools/traffic), which stays passive — it observes -BEYOND ⇄ FB4 traffic and never transmits. +[`tools/traffic`](../../tools/traffic), which observes BEYOND ⇄ FB4 traffic and +transmits nothing except its `bin/replay` experiment, run by hand. ### `wavegrid config` (or `wavegrid --print-config`) diff --git a/tools/traffic/PROTOCOL.md b/tools/traffic/PROTOCOL.md index a69d0fa..24feabb 100644 --- a/tools/traffic/PROTOCOL.md +++ b/tools/traffic/PROTOCOL.md @@ -22,6 +22,7 @@ the part you use while something is broken. - [What is still missing, and how to capture it](#what-is-still-missing-and-how-to-capture-it) - [Diagnostic playbook](#diagnostic-playbook) - [Ways in that are not this protocol](#ways-in-that-are-not-this-protocol) +- [Guided experiments, and the one tool that transmits](#guided-experiments-and-the-one-tool-that-transmits) ## The installation, as the network sees it @@ -248,6 +249,35 @@ readable either. The 32-byte `0x00028010` body is all zeroes — the one body in the protocol that is plainly not encrypted, which is itself a small hint that the opacity is applied per message type rather than to the whole connection. +### No frame body is ever sent twice + +The question that decides whether a captured frame could simply be replayed: +does BEYOND ever emit the same body twice? Over 1,161 frames of *static* content +across two captures — an idle slice and the amber capture, i.e. the best case for +repetition, since the picture is not changing at all: + +| | result | +| --- | --- | +| distinct bodies | 1,161 of 1,161 — zero repeats | +| body bytes constant across every frame | 0 of 2,360 | +| bytes two consecutive bodies share | 1–21 of 2,360 (chance alone ≈ 9) | +| bodies shared between two devices getting the same scene | 0 | + +Reproduce with `./bin/decode --repeats`. + +A static scene re-encrypted into a completely different 2,360 bytes every 16 ms +means each frame carries a nonce, a counter, or a stream-cipher position. Two +consequences worth stating plainly, because they close off the two obvious +shortcuts: + +- **Replaying a captured frame is not a route in.** A copy is either rejected as + stale or, at best, decrypts to a single stale picture — and there is no + repetition anywhere to build a mapping from. +- **A known-plaintext attack has nothing to bite on.** Byte-identical input + (blackout, held amber) produces unrelated ciphertext, so we cannot line up + "this look" against "these bytes", which is the technique that would otherwise + work on a home-grown scheme. + 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. Per the @@ -358,6 +388,22 @@ tag, and the tooling is already built for it: Worth doing for the handful that matter (colour balance, scan rate, blanking delay) rather than all 223. +**5. Nobody has tried sending the 16062 lines.** Those lines are plaintext, and +the format is fully understood — so the cheapest remaining question is whether +anything on the network *acts* on them, or whether the broadcast is only BEYOND +narrating itself. This is a test in the room, not a capture: + +``` +close BEYOND completely (tray included) +./bin/session replay --host [,…] +``` + +Evidence for the pessimistic answer: 16062 has only ever been seen host → +network, never toward BEYOND, and the FB4s take their orders on 3348. So the +expectation is that nothing moves. It is still worth ten minutes, because the +result is unambiguous either way and it is the only cheap experiment left that +could end in direct control. + Also worth having, cheaply, while someone is at the machine: BEYOND's projector list screenshot (to explain `45.4`), the FB4 firmware versions, and a listing of what content is on each SD card — the last one decides whether the ArtNet route @@ -400,6 +446,34 @@ also stops being available to BEYOND. Untested here, and it would be the first thing in this project that transmits toward hardware; noted so the option isn't rediscovered from scratch. +## Guided experiments, and the one tool that transmits + +`./bin/session --list` runs the experiments above end to end: it starts the +capture, walks the operator through what to do at the machine, stops the capture, +decodes it, and says what the result means. Three of them: + +| experiment | answers | transmits | +| --- | --- | --- | +| `./bin/session handshake` | is there a key exchange on connect? (gap 1) | no | +| `./bin/session osc-rgba` | do our OSC values land, and on which zone? (gaps 2, 3) | no | +| `./bin/session replay --host ` | does anything act on the 16062 lines? (gap 5) | yes | + +`./bin/replay` is the only tool here that puts packets on the wire, and only when +given `--transmit` **and** `--host`. Without them it prints the exact datagrams +it would send and exits, which is also the fastest way to check the format +against this document: + +``` +./bin/replay --zone all --colour amber --sweep +``` + +It sends nothing but BEYOND's own plaintext live-control lines — the same bytes +BEYOND broadcasts, at the same rate a human moving a slider would — never +fabricated frame-stream traffic, which is not constructible anyway. Rules for +running it: BEYOND closed (otherwise a change in the room proves nothing about +what caused it), somebody watching the heads, E-stop in reach, and never as an +unattended loop. + ## What this toolkit is good for - Confirming an OSC message reached BEYOND, and what value it set, per zone @@ -411,5 +485,7 @@ rediscovered from scratch. told to draw nothing". - Naming settings tags by controlled experiment, when someone has the machine. -Everything in `tools/traffic` reads files and sockets. It never transmits toward -the hardware. +Everything in `tools/traffic` reads files and sockets, with one deliberate +exception: `./bin/replay --transmit`, which exists to answer the one question +capture analysis cannot, and which sends only the plaintext live-control lines +BEYOND itself broadcasts. diff --git a/tools/traffic/README.md b/tools/traffic/README.md index d686ed9..6cf4549 100644 --- a/tools/traffic/README.md +++ b/tools/traffic/README.md @@ -4,9 +4,11 @@ A small CLI toolkit for capturing and reverse-engineering the network traffic between Pangolin BEYOND and Pangolin laser hardware (an FB4), built on Wireshark's command-line tools. -**Passive only.** Nothing here transmits, replays, or spoofs anything: it lists -interfaces, reads the neighbour table, captures, and analyses files on disk. No -packet is ever sent toward the laser. Understanding the protocol comes first. +**Passive, with one deliberate exception.** Everything here lists interfaces, +reads the neighbour table, captures and analyses files on disk. The exception is +`bin/replay`, which sends BEYOND's own plaintext live-control lines — and only +when given `--transmit` and a `--host`; without them it prints the bytes it would +send and exits. Nothing here ever fabricates frame-stream traffic. Nothing in the Wavegrid app depends on these tools being installed — the app only looks for them when you open the Traffic tab (Advanced → Traffic), which @@ -17,7 +19,8 @@ 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`, `decode` and `rgba`. +- `python3` (macOS and Linux both have it) for `compare`, `decode`, `rgba` and + `replay`. - 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. @@ -97,7 +100,38 @@ For watching that live instead of after the fact: 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. +value move. Silence means nothing is arriving. This listener only receives. + +## Guided experiments + +``` +./bin/session --list # what each experiment answers +./bin/session handshake # BEYOND's connection setup, incl. the TCP SYN +./bin/session osc-rgba # our OSC and BEYOND's echo, in one file +./bin/session replay --host 169.254.53.5 # does anything act on the 16062 lines? (transmits) +``` + +Each one starts the capture, tells you what to do at the machine, stops the +capture, decodes it, and says what the result means — so the answer doesn't +depend on remembering the right `tshark` filter at 2am. These are the open +questions from [PROTOCOL.md](PROTOCOL.md), one command each. + +## Sending live-control lines + +``` +./bin/replay --zone all --colour amber --sweep # dry run: print the bytes +./bin/replay --zone 1 --sweep --transmit --host 169.254.53.5 +``` + +The format BEYOND broadcasts is understood; what nobody has tested is whether +anything *listens*. The captures suggest not (16062 has only ever been seen +going host → network), but it is a ten-minute experiment with an unambiguous +result. Run it with BEYOND closed — otherwise a change in the room proves nothing +about what caused it — someone watching the heads, and the E-stop in reach. + +`./bin/decode --repeats` answers the companion question, from captures +we already have: BEYOND never sends the same frame body twice, not even for a +static scene, so a captured frame cannot be replayed. [PROTOCOL.md](PROTOCOL.md) is the full report on what the captures so far actually say: every port and message type, the header layout byte by byte, the @@ -160,13 +194,16 @@ 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 +bin/session run one open question end to end: capture, guide, decode +bin/replay send BEYOND's live-control lines (the only tool that transmits) 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 +lib/replay.py the datagrams, and the sending ``` -The decoder has tests, run from the repo root: +The decoder and the replay builder have tests, run from the repo root: ``` python3 -m unittest discover -s tools/traffic/lib -p '*_test.py' diff --git a/tools/traffic/bin/replay b/tools/traffic/bin/replay new file mode 100755 index 0000000..408f238 --- /dev/null +++ b/tools/traffic/bin/replay @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# +# replay — send BEYOND's plaintext live-control lines yourself, to find out +# whether anything on the network acts on them. +# +# Usage: +# ./bin/replay # dry run: print the bytes +# ./bin/replay --zone all --colour amber +# ./bin/replay --sweep --zone 1 --transmit --host 169.254.53.5 +# +# This is the only tool here that transmits, and only with --transmit. Every +# other tool in tools/traffic stays receive-only. +# +# Run it with BEYOND closed — otherwise a change in the room proves nothing +# about who caused it. Have someone watching the heads and the E-stop in reach. + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh" + +command -v python3 >/dev/null || { echo 'error: python3 is required for replay' >&2; exit 127; } + +exec python3 "$TRAFFIC_ROOT/lib/replay.py" "$@" diff --git a/tools/traffic/bin/session b/tools/traffic/bin/session new file mode 100755 index 0000000..edefacf --- /dev/null +++ b/tools/traffic/bin/session @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# +# session — run one of the experiments PROTOCOL.md asks for, start to finish: +# start the capture, walk you through what to do at the machine, stop the +# capture, then decode it and say what the result means. +# +# Usage: +# ./bin/session handshake [--iface IF] BEYOND's connection setup, incl. TCP SYN +# ./bin/session osc-rgba [--iface IF] our OSC going in and BEYOND's echo, in one file +# ./bin/session replay --host IP[,IP] does anything act on the 16062 lines? (transmits) +# ./bin/session --list +# +# Everything is passive except the `replay` step inside the replay experiment, +# which asks before it sends. Captures land in the capture directory with a +# label naming the experiment, so tomorrow's file is still identifiable. + +source "$(dirname "${BASH_SOURCE[0]}")/../lib/common.sh" +eval "$(parse_common_args "$@")" + +BIN="$TRAFFIC_ROOT/bin" +iface_args=() +experiment="" +hosts="" +while [[ $# -gt 0 ]]; do + case "$1" in + --iface|-i) iface_args=(--iface "${2:-}"); shift 2 ;; + --iface=*) iface_args=(--iface "${1#--iface=}"); shift ;; + --host) hosts="${2:-}"; shift 2 ;; + --host=*) hosts="${1#--host=}"; shift ;; + --list) experiment=list; shift ;; + -h|--help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) experiment="$1"; shift ;; + esac +done + +if [[ "$experiment" == list || -z "$experiment" ]]; then + cat <<'LIST' +experiments: + handshake Capture BEYOND opening its connections. Every capture so far began + mid-stream, so we have never seen the TCP handshake on 3348 — the + only place a key exchange could be visible. Answers: is there one? + osc-rgba Capture our OSC going into BEYOND and BEYOND's own broadcast of + what it did with it, in one file. Answers: which zone numbers we + should be sending, and whether the values land. + replay Send BEYOND's plaintext live-control lines ourselves, BEYOND closed. + Answers: does anything on the network act on them, or is 16062 + output only? This one transmits. +LIST + exit 0 +fi + +pause() { + printf '\n\033[1m%s\033[0m\n' "$1" + shift + for line in "$@"; do printf ' %s\n' "$line"; done + printf '\nPress ENTER when done (Ctrl-C to abort) ' + read -r _ +} + +start_capture() { + "$BIN/capture" --label "$1" --background ${iface_args+"${iface_args[@]}"} >/dev/null + printf 'capturing…\n' +} + +stop_capture() { + local out + out="$("$BIN/capture" --stop)" + # Only the filename goes to stdout, so callers can capture it; the packet + # counts are for the operator to read. + printf '%s\n' "$out" >&2 + printf '%s\n' "$out" | sed -n '1s/.*→ //p' +} + +syn_count() { + local file="$1" tshark + tshark="$(need_tool tshark)" + "$tshark" -r "$file" -n -Y 'tcp.flags.syn==1 && tcp.port==3348' 2>/dev/null | wc -l | tr -d ' ' +} + +case "$experiment" in + handshake) + cat <<'INTRO' +== handshake == +We need the moment BEYOND opens its connections to the FB4s. Capture first, +BEYOND second — the other way round is why every existing capture starts +mid-stream and tells us nothing about connection setup. +INTRO + pause 'Close BEYOND completely.' \ + 'Quit the application, and check the tray — it can keep running there.' \ + 'The FB4s can stay powered; it is BEYOND connecting that we want to see.' + start_capture handshake + pause 'Now launch BEYOND and let it find the projectors.' \ + 'Wait until the projector list shows the heads as connected (~30s).' \ + 'Do not paint or send anything yet — setup is all we want in this file.' + file="$(stop_capture)" + [[ -n "$file" ]] || { echo 'no capture file — did the capture start?' >&2; exit 1; } + echo + syns="$(syn_count "$file")" + echo "TCP SYNs on 3348 in this capture: $syns" + if [[ "$syns" == 0 ]]; then + echo ' Still no handshake — BEYOND was probably already connected, or its' + echo ' traffic never crossed the capture interface. Check ./bin/interfaces.' + else + echo ' Got it. What matters now is the first few messages of each connection:' + echo ' a key exchange would be a short, structured, non-8.0-entropy prelude.' + fi + echo + "$BIN/decode" "$file" --hex 96 + ;; + + osc-rgba) + cat <<'INTRO' +== osc-rgba == +One file containing both halves of the OSC path: what we send, and what BEYOND +says it did with it. That link has only ever been inferred from two separate +captures. +INTRO + pause 'Open BEYOND, and open its RGBA panel.' \ + 'BEYOND only broadcasts live-control values while that panel is open' \ + '(BEYOND.ini [Settings] ShowRGBAPanel=1 — verify the value on this machine).' \ + 'Put the zone you are testing under live control.' + start_capture osc-rgba + pause 'Send a few values from Wavegrid, one at a time.' \ + 'e.g. wavegrid signals send /beyond/zone/1/livecontrol/red 255' \ + ' wavegrid signals send /beyond/zone/1/livecontrol/Brightness 50' \ + 'Change one thing at a time and note the order — the timeline below lines' \ + 'up against it.' + file="$(stop_capture)" + [[ -n "$file" ]] || { echo 'no capture file — did the capture start?' >&2; exit 1; } + echo + "$BIN/decode" "$file" --timeline + cat <<'AFTER' +what to read out of that: + our OSC arrived → packets to udp/8000 from this machine + BEYOND acted on it → the same values appear under live control, on 16062 + zone numbering settled → the zone BEYOND reports is the zone we addressed; + if we send 240–245 and BEYOND reports 1–6, the + project's projector map is what needs fixing +AFTER + ;; + + replay) + cat <<'INTRO' +== replay == +Does anything act on BEYOND's plaintext live-control lines, or does only BEYOND +emit them? BEYOND has to be closed, or a change in the room proves nothing about +what caused it. + +This experiment transmits. Somebody watches the heads, E-stop in reach. +INTRO + if [[ -z "$hosts" ]]; then + echo + echo 'error: this experiment needs --host with the addresses to send to,' >&2 + echo ' e.g. --host 169.254.53.5,169.254.200.242 (the device inventory in' >&2 + echo ' PROTOCOL.md lists them, or run ./bin/discover while BEYOND is up).' >&2 + exit 2 + fi + pause 'Close BEYOND completely, and confirm the heads are idle.' \ + 'Tray included. Nothing else should be driving the lasers.' + start_capture replay + echo + echo 'dry run first — these are the exact bytes:' + "$BIN/replay" --zone all --colour amber --sweep | head -20 + pause 'Ready to transmit that sweep?' \ + "ENTER runs it against each of ${hosts} in turn, brightness ramping 0→100→0." \ + 'Watch the heads. Ctrl-C now if anything is unsafe.' + for host in ${hosts//,/ }; do + echo "→ $host" + "$BIN/replay" --zone all --colour amber --sweep --transmit --host "$host" || true + done + file="$(stop_capture)" + [[ -n "$file" ]] || { echo 'no capture file — did the capture start?' >&2; exit 1; } + echo + "$BIN/decode" "$file" + cat <<'AFTER' +record what happened in the room, because the wire cannot tell you: + heads moved → something acts on these lines. Which host? Repeat one at a + time to find out, then this is a control path we can use. + heads still → 16062 is BEYOND narrating itself, as the captures suggested. + Colour/brightness without Pangolin then needs an input the + hardware actually listens on — a question for the vendor. +AFTER + ;; + + *) + echo "unknown experiment: $experiment (see ./bin/session --list)" >&2 + exit 2 ;; +esac diff --git a/tools/traffic/lib/pangolin.py b/tools/traffic/lib/pangolin.py index 0096679..a5720a6 100644 --- a/tools/traffic/lib/pangolin.py +++ b/tools/traffic/lib/pangolin.py @@ -23,6 +23,7 @@ from __future__ import annotations import argparse +import hashlib import math import subprocess import sys @@ -355,6 +356,103 @@ def report_stream(packets: list[Packet], hex_bytes: int) -> None: print() +def stream_messages(packets: list[Packet]) -> dict[str, list[bytes]]: + """Messages per direction of each TCP 3348 connection, in capture order.""" + 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) + return { + key: split_messages(b''.join(p.payload for p in ps)) + for key, ps in sorted(streams.items()) + } + + +@dataclass +class Repeats: + """Whether a stream's frame bodies ever say the same thing twice. + + The question this answers is whether the encryption is per-frame. Replaying a + captured frame can only work if an identical body is ever legitimately sent + twice — otherwise each one carries a nonce or a counter, and a copy is either + rejected or (worse, silently) meaningless. + """ + + frames: int + distinct: int + body_len: int + constant_offsets: int + shared_min: int + shared_max: int + + @property + def repeated(self) -> int: + return self.frames - self.distinct + + @property + def chance(self) -> float: + """Bytes two unrelated 256-value sequences would match on by luck.""" + return self.body_len / 256 + + +def body_repeats(bodies: list[bytes]) -> Repeats | None: + if len(bodies) < 2: + return None + width = min(len(b) for b in bodies) + shared = [sum(1 for x, y in zip(a, b) if x == y) for a, b in zip(bodies, bodies[1:])] + return Repeats( + frames=len(bodies), + distinct=len({hashlib.sha256(b).digest() for b in bodies}), + body_len=width, + constant_offsets=sum( + 1 for i in range(width) if all(b[i] == bodies[0][i] for b in bodies) + ), + shared_min=min(shared), + shared_max=max(shared), + ) + + +def report_repeats(packets: list[Packet]) -> None: + streams = stream_messages(packets) + bodies_by_stream = { + key: [m[HEADER_LEN:] for m in messages if u32(m, 4) == STREAM_TYPE_FRAME] + for key, messages in streams.items() + } + bodies_by_stream = {k: v for k, v in bodies_by_stream.items() if v} + + print('== do frame bodies ever repeat? (TCP 3348) ==') + if not bodies_by_stream: + print(' no frames in this capture\n') + return + + for key, bodies in bodies_by_stream.items(): + r = body_repeats(bodies) + if r is None: + print(f' {key}: {len(bodies)} frame — need two to compare') + continue + print(f' {key}') + print(f' {r.frames} frames, {r.distinct} distinct bodies, ' + f'{r.repeated} repeated') + print(f' bytes identical across every frame: {r.constant_offsets}/{r.body_len}') + print(f' consecutive bodies share {r.shared_min}–{r.shared_max} bytes ' + f'(chance alone ≈ {r.chance:.0f})') + + # The same scene goes to several projectors at once. If two devices ever got + # byte-identical bodies, the encryption would be per-scene, not per-frame and + # per-connection — so this is worth asking even when repeats within one + # stream come up empty. + keys = list(bodies_by_stream) + for i, left in enumerate(keys): + for right in keys[i + 1:]: + a = {hashlib.sha256(b).digest() for b in bodies_by_stream[left]} + same = sum(1 for b in bodies_by_stream[right] if hashlib.sha256(b).digest() in a) + print(f' {left.split("->")[-1]} vs {right.split("->")[-1]}: ' + f'{same} bodies in common') + print(' identical bodies are what a replayable stream looks like; none means\n' + ' every frame is encrypted afresh and a captured frame cannot be reused') + print() + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) parser.add_argument('capture') @@ -363,6 +461,8 @@ def main() -> None: 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') + parser.add_argument('--repeats', action='store_true', + help='ask whether any frame body is ever sent twice (replayability)') args = parser.parse_args() packets = read_packets(args.tshark, args.capture) @@ -370,6 +470,8 @@ def main() -> None: report_devices(packets) report_rgba(packets, args.timeline) report_stream(packets, args.hex) + if args.repeats: + report_repeats(packets) if __name__ == '__main__': diff --git a/tools/traffic/lib/pangolin_test.py b/tools/traffic/lib/pangolin_test.py index acde700..1647b17 100644 --- a/tools/traffic/lib/pangolin_test.py +++ b/tools/traffic/lib/pangolin_test.py @@ -22,12 +22,14 @@ STREAM_TYPE_FRAME, Header, Packet, + body_repeats, entropy, format_rate, parse_announce, parse_rgba_panel, parse_settings, report_devices, + report_repeats, report_rgba, report_stream, split_messages, @@ -258,6 +260,50 @@ def test_a_lone_message_gets_no_rate(self): self.assertNotIn('/s', text) +class BodyRepeatsTest(unittest.TestCase): + """Whether a frame body is ever sent twice decides if replay is even possible.""" + + def test_identical_bodies_are_counted_as_repeats(self): + r = body_repeats([bytes(range(256))] * 3) + self.assertEqual(r.frames, 3) + self.assertEqual(r.distinct, 1) + self.assertEqual(r.repeated, 2) + self.assertEqual(r.constant_offsets, 256) + + def test_unrelated_bodies_share_nothing_and_repeat_nothing(self): + bodies = [bytes(range(256)), bytes(reversed(range(256)))] + r = body_repeats(bodies) + self.assertEqual(r.distinct, 2) + self.assertEqual(r.repeated, 0) + self.assertEqual(r.constant_offsets, 0) + + def test_one_body_cannot_be_compared(self): + self.assertIsNone(body_repeats([bytes(16)])) + + +class ReportRepeatsTest(unittest.TestCase): + def render(self, fn, *args) -> str: + out = io.StringIO() + with redirect_stdout(out): + fn(*args) + return out.getvalue() + + def test_says_so_when_there_are_no_frames(self): + self.assertIn('no frames', self.render(report_repeats, [])) + + def test_reports_repeated_bodies_when_a_body_recurs(self): + body = bytes(range(256)) + stream = message(STREAM_TYPE_FRAME, body, 1) + message(STREAM_TYPE_FRAME, body, 2) + text = self.render(report_repeats, [tcp(stream)]) + self.assertIn('2 frames, 1 distinct bodies, 1 repeated', text) + + def test_reports_none_repeated_when_every_body_differs(self): + stream = (message(STREAM_TYPE_FRAME, bytes(range(256)), 1) + + message(STREAM_TYPE_FRAME, bytes(reversed(range(256))), 2)) + text = self.render(report_repeats, [tcp(stream)]) + self.assertIn('2 distinct bodies, 0 repeated', text) + + class FormatRateTest(unittest.TestCase): def test_rate_over_a_span(self): self.assertEqual(format_rate(62, 1.0), '62.0/s') diff --git a/tools/traffic/lib/replay.py b/tools/traffic/lib/replay.py new file mode 100644 index 0000000..75636cc --- /dev/null +++ b/tools/traffic/lib/replay.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Send BEYOND's own plaintext live-control lines onto the network. + +This is the one tool in this toolkit that transmits, and it exists to answer a +single question that no amount of capture analysis can: BEYOND broadcasts its +live-control state as text on UDP 16062 (`ControlZone 3` / `RGBA 0, 229` / +`Brightness 97`) — does *anything* act on those lines, or is that broadcast +purely BEYOND narrating itself to the network? + +What the captures say, so nobody runs this expecting too much: 16062 has only +ever been seen going host → network, never toward BEYOND, and the FB4s take +their orders on TCP 3348 instead. So the honest prediction is that nothing +listens and the lasers do not move. That prediction is cheap to falsify and +expensive to assume, which is the whole point of the experiment: run it with +BEYOND closed, watch the heads, and record what happened either way. + +Dry-run by default: it prints the exact bytes it would send and exits. Adding +--transmit is what puts packets on the wire, and it needs an explicit --host. + +Safety: this points at laser hardware. Have someone watching the heads, keep the +E-stop in reach, and never run it as an unattended loop. +""" + +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, RGBA_CHANNELS # noqa: E402 + +# Named colours in the terms the captures used: amber is the look Grace runs, +# and its exact values are the ones BEYOND held during the paint capture. +COLOURS = { + 'amber': (255, 219, 59), + 'white': (255, 255, 255), + 'black': (0, 0, 0), + 'red': (255, 0, 0), + 'green': (0, 255, 0), + 'blue': (0, 0, 255), +} + +CHANNEL_NUMBERS = {name: number for number, name in RGBA_CHANNELS.items()} + + +def parse_colour(text: str) -> tuple[int, int, int]: + if text in COLOURS: + return COLOURS[text] + parts = text.split(',') + if len(parts) != 3: + raise ValueError(f'colour must be one of {", ".join(COLOURS)}, or r,g,b') + values = tuple(int(p) for p in parts) + if any(v < 0 or v > 255 for v in values): + raise ValueError('colour channels are 0–255') + return values[0], values[1], values[2] + + +def datagram(zone: int, line: str) -> bytes: + """One live-control update, framed exactly as BEYOND broadcasts it. + + Each datagram names its zone and then carries a single value; the zone line + is context for the line under it, so they always travel together. + """ + return f'ControlZone {zone}\r\n{line}\r\n'.encode('ascii') + + +def build_datagrams( + zones: list[int], + rgb: tuple[int, int, int] | None = None, + alpha: int | None = None, + brightness: int | None = None, +) -> list[bytes]: + """The datagrams for one state, in the order BEYOND emits them. + + Colour first, then alpha (the live-control override), then brightness — + matching the order the paint capture shows, so a listener that cares about + ordering sees what it would have seen from BEYOND. + """ + out: list[bytes] = [] + for zone in zones: + if rgb is not None: + for name, value in zip(('red', 'green', 'blue'), rgb): + out.append(datagram(zone, f'RGBA {CHANNEL_NUMBERS[name]}, {value}')) + if alpha is not None: + out.append(datagram(zone, f'RGBA {CHANNEL_NUMBERS["alpha"]}, {alpha}')) + if brightness is not None: + out.append(datagram(zone, f'Brightness {brightness}')) + return out + + +def build_sweep(zones: list[int], rgb: tuple[int, int, int], steps: int) -> list[bytes]: + """A brightness ramp, so a response is unmistakable rather than a guess. + + A single value could coincide with whatever the heads already held; a ramp + from dark to full and back cannot be mistaken for a coincidence by someone + watching the room. + """ + levels = [round(100 * i / (steps - 1)) for i in range(steps)] if steps > 1 else [100] + out = build_datagrams(zones, rgb=rgb, alpha=255) + for level in levels + list(reversed(levels[:-1])): + out.extend(build_datagrams(zones, brightness=level)) + return out + + +def describe(datagrams: list[bytes]) -> str: + return '\n'.join( + f' {d!r:<44} {d.hex(" ")}' + for d in datagrams + ) + + +def send(host: str, port: int, datagrams: list[bytes], interval: float) -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # A .255 target needs broadcast permission; harmless for a unicast host. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + started = time.monotonic() + for d in datagrams: + sock.sendto(d, (host, port)) + print(f'{time.monotonic() - started:8.3f} → {host}:{port} {d!r}', flush=True) + time.sleep(interval) + sock.close() + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__.split('\n')[0], + epilog='Dry-run unless --transmit. Watch the heads; keep the E-stop in reach.', + ) + parser.add_argument('--host', help='where to send: an FB4 IP, or a broadcast address') + parser.add_argument('--port', type=int, default=BEYOND_RGBA_PORT) + parser.add_argument('--zone', default='1', + help='zone number, a comma list, or "all" for 1–6') + parser.add_argument('--colour', '--color', dest='colour', default='amber', + help=f'{", ".join(COLOURS)}, or r,g,b') + parser.add_argument('--brightness', type=int, default=100, help='0–100') + parser.add_argument('--sweep', action='store_true', + help='ramp brightness up and down instead of one value') + parser.add_argument('--steps', type=int, default=11, help='steps in the sweep ramp') + parser.add_argument('--interval', type=float, default=0.05, + help='seconds between datagrams') + parser.add_argument('--transmit', action='store_true', + help='actually put these packets on the wire') + args = parser.parse_args() + + try: + rgb = parse_colour(args.colour) + except ValueError as err: + sys.exit(f'error: {err}') + if not 0 <= args.brightness <= 100: + sys.exit('error: brightness is 0–100') + + zones = list(range(1, 7)) if args.zone == 'all' else [ + int(z) for z in args.zone.split(',') if z.strip() + ] + + if args.sweep: + datagrams = build_sweep(zones, rgb, max(2, args.steps)) + else: + datagrams = build_datagrams(zones, rgb=rgb, alpha=255, brightness=args.brightness) + + if not args.transmit: + print(f'dry run — {len(datagrams)} datagrams for zones {zones}, ' + f'colour {rgb}, would go to udp/{args.port}:') + print(describe(datagrams)) + print('\nadd --transmit --host to send them. Close BEYOND first, or you ' + 'will not know\nwhich of the two moved the lasers.') + return + + if not args.host: + sys.exit('error: --transmit needs --host (an FB4 IP, or a broadcast address)') + + print(f'transmitting {len(datagrams)} datagrams to {args.host}:{args.port}') + print('BEYOND should be closed for this to prove anything. Watch the heads.\n') + send(args.host, args.port, datagrams, args.interval) + print('\nsent. Nothing acknowledges UDP, so the result is what you saw in the room:') + print(' heads changed → something on the network acts on these lines') + print(' heads still → the 16062 broadcast is output only, as the captures suggest') + + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print() diff --git a/tools/traffic/lib/replay_test.py b/tools/traffic/lib/replay_test.py new file mode 100644 index 0000000..3fdc991 --- /dev/null +++ b/tools/traffic/lib/replay_test.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Tests for the live-control replay builder. + +This is the one tool that transmits, so what it puts on the wire is pinned here +byte for byte — against the lines BEYOND itself broadcast in the paint capture. +Nothing here opens a socket. +""" + +from __future__ import annotations + +import unittest + +from replay import build_datagrams, build_sweep, datagram, parse_colour + + +class ColourTest(unittest.TestCase): + def test_amber_is_the_value_beyond_held_at_grace(self): + self.assertEqual(parse_colour('amber'), (255, 219, 59)) + + def test_explicit_triples_are_allowed(self): + self.assertEqual(parse_colour('12,34,56'), (12, 34, 56)) + + def test_rejects_a_channel_out_of_range(self): + with self.assertRaises(ValueError): + parse_colour('0,0,300') + + def test_rejects_something_that_is_neither(self): + with self.assertRaises(ValueError): + parse_colour('puce') + + +class DatagramTest(unittest.TestCase): + def test_matches_beyonds_own_framing(self): + # Byte for byte what BEYOND broadcast on UDP 16062. + self.assertEqual(datagram(3, 'RGBA 0, 229'), + b'ControlZone 3\r\nRGBA 0, 229\r\n') + + def test_brightness_is_its_own_line(self): + self.assertEqual(datagram(1, 'Brightness 97'), + b'ControlZone 1\r\nBrightness 97\r\n') + + +class BuildDatagramsTest(unittest.TestCase): + def test_colour_then_alpha_then_brightness(self): + self.assertEqual( + build_datagrams([2], rgb=(1, 2, 3), alpha=255, brightness=50), + [ + b'ControlZone 2\r\nRGBA 0, 1\r\n', + b'ControlZone 2\r\nRGBA 1, 2\r\n', + b'ControlZone 2\r\nRGBA 2, 3\r\n', + b'ControlZone 2\r\nRGBA 3, 255\r\n', + b'ControlZone 2\r\nBrightness 50\r\n', + ]) + + def test_each_zone_is_addressed_in_turn(self): + out = build_datagrams([1, 2], brightness=0) + self.assertEqual(out, [b'ControlZone 1\r\nBrightness 0\r\n', + b'ControlZone 2\r\nBrightness 0\r\n']) + + def test_omitted_values_are_left_alone(self): + self.assertEqual(build_datagrams([1], rgb=(0, 0, 0)), + [b'ControlZone 1\r\nRGBA 0, 0\r\n', + b'ControlZone 1\r\nRGBA 1, 0\r\n', + b'ControlZone 1\r\nRGBA 2, 0\r\n']) + + +class SweepTest(unittest.TestCase): + def test_ramps_up_then_back_down(self): + levels = [ + int(d.split(b'Brightness ')[1].rstrip(b'\r\n')) + for d in build_sweep([1], (255, 219, 59), 3) + if b'Brightness' in d + ] + self.assertEqual(levels, [0, 50, 100, 50, 0]) + + def test_sets_the_colour_and_override_before_ramping(self): + first = build_sweep([1], (255, 219, 59), 2)[:4] + self.assertEqual(first[-1], b'ControlZone 1\r\nRGBA 3, 255\r\n') + + +if __name__ == '__main__': + unittest.main()