From 3a1920f0860f2f8d8b9bd4a1fa508f6af31ce85c Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 19 Aug 2026 01:38:13 +0000 Subject: [PATCH] docs(traffic): what the sliced captures settled, and flag FB4s getting no frames The show machine's 169.254.45.4 announces itself on 9022 in every capture but has zero TCP 3348 traffic in any of them: BEYOND streams to five of the six FB4s, so that projector could only ever be dark. decode now reports it per device, since being on the network and being driven are different things. PROTOCOL.md also records why OSC can land and still do nothing (RGBA panel closed = no 16062 broadcast and gated livecontrol addresses), that no capture yet contains a SYN so the connection setup remains unseen, that 9022 is only discovery while 3348 carries the ~62/s frames, and the documented FB4 OSC/ArtNet routes that don't involve this protocol at all. --- tools/traffic/PROTOCOL.md | 48 +++++++++++++++++++++++++++--- tools/traffic/lib/pangolin.py | 15 ++++++++++ tools/traffic/lib/pangolin_test.py | 30 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/tools/traffic/PROTOCOL.md b/tools/traffic/PROTOCOL.md index f08c7a9..3b59b79 100644 --- a/tools/traffic/PROTOCOL.md +++ b/tools/traffic/PROTOCOL.md @@ -1,9 +1,10 @@ # What BEYOND and an FB4 actually say to each other -Findings from two captures taken on the Grace Cathedral show machine +Findings from 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. +One capture is idle-ish, one is a paint session, and a later set is sliced by +action (idle baseline, hand-sent OSC, `signals probe` walk, idle tail). 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 @@ -80,7 +81,7 @@ Types observed: | type | name used here | size | rate | direction | | --- | --- | --- | --- | --- | -| `0x00030E02` | frame | 2392 B | 16–23/s per device | BEYOND → FB4 | +| `0x00030E02` | frame | 2392 B | 16–62/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 | @@ -107,11 +108,50 @@ provide (key material, instrumented software, or vendor documentation) — and per the standing constraint on this toolkit, nothing here transmits toward the hardware regardless. +## Two things the sliced capture set settled + +**One FB4 was getting no frames at all.** `169.254.45.4` (`FB4E`, id 566604) +announced itself on 9022 in every capture, but has zero TCP 3348 traffic in any +of them: BEYOND was streaming to five devices, not six, so that projector could +only ever have been dark. `decode` now says so per device — being on the network +and being driven are different things, and only the second one lights a laser. + +**A closed RGBA panel is why OSC can land and do nothing.** Those captures show +90 OSC packets delivered to port 8000 with no ICMP port-unreachable in reply — +delivery proven — and *zero* packets on 16062, where the earlier paint capture +had 675. With the panel closed BEYOND does not broadcast, and the `livecontrol` +colour addresses are gated, so "the messages arrived" and "the colour changed" +were never the same claim. Silence on 16062 is the signal to check +`ShowRGBAPanel` before debugging anything else. + +What they did *not* settle: none of them contains a TCP SYN, so BEYOND's +connection setup has still never been captured. If key material is negotiated +there, it remains unseen — capture with the FB4s power-cycled and BEYOND started +afterwards to catch it. + +## Ports, precisely + +Worth stating because it is easy to get backwards from packet counts: 9022 is +*only* discovery — 38 packets across a 7-second idle baseline. The frames are +TCP 3348, ~62 messages/s per device, continuous even through a blackout. A high +packet rate on the wire is the frame stream, never discovery. + +## Ways in that are not this protocol + +Since the frame stream can't be driven, the documented non-BEYOND routes matter. +Per Pangolin's wiki, an FB4 listens for OSC on port 8000 for its own settings +including `operation_mode`, and in ArtNet mode presents a 16- or 39-channel DMX +fixture profile over ethernet. Both are colour/brightness/playback control over +content stored on the FB4's SD card — not arbitrary geometry from us, which the +encrypted stream is the only path to. Untested here; noted so the option isn't +rediscovered from scratch. + ## 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 every FB4 is present, with its id/MAC, without opening BEYOND — + and which of them BEYOND is actually streaming to. - 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/lib/pangolin.py b/tools/traffic/lib/pangolin.py index 3183047..3b95cf5 100644 --- a/tools/traffic/lib/pangolin.py +++ b/tools/traffic/lib/pangolin.py @@ -221,6 +221,15 @@ def read_packets(tshark: str, path: str) -> list[Packet]: return packets +def streaming_ips(packets: list[Packet]) -> set[str]: + """IPs BEYOND has a frame stream with, either direction.""" + return { + ip + for p in packets if p.proto == 'tcp' and FB4_STREAM_PORT in (p.sport, p.dport) + for ip in (p.src, p.dst) + } + + def report_devices(packets: list[Packet]) -> None: devices: dict[str, Device] = {} host_names: set[str] = set() @@ -245,10 +254,16 @@ def report_devices(packets: list[Packet]) -> None: print('== devices (UDP 9022) ==') if host_names: print(f' BEYOND host: {", ".join(sorted(host_names))}') + streaming = streaming_ips(packets) 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)}') + if streaming and ip not in streaming: + # It is on the network and announcing, but BEYOND never opened a + # frame stream to it: a projector that will stay dark. + print(' no frame stream — BEYOND is not sending to ' + 'this device') print() diff --git a/tools/traffic/lib/pangolin_test.py b/tools/traffic/lib/pangolin_test.py index 07fd997..c42805d 100644 --- a/tools/traffic/lib/pangolin_test.py +++ b/tools/traffic/lib/pangolin_test.py @@ -30,6 +30,7 @@ report_rgba, report_stream, split_messages, + streaming_ips, ) @@ -155,6 +156,16 @@ def test_empty(self): self.assertEqual(entropy(b''), 0.0) +class StreamingIpsTest(unittest.TestCase): + def test_both_ends_of_a_frame_stream_count(self): + self.assertEqual(streaming_ips([tcp(b'x')]), + {'169.254.42.165', '169.254.45.4'}) + + def test_discovery_traffic_is_not_a_frame_stream(self): + packet = udp(b'x', sport=FB4_DISCOVERY_PORT, dport=FB4_DISCOVERY_PORT) + self.assertEqual(streaming_ips([packet]), set()) + + class ReportTest(unittest.TestCase): """The reports must say 'nothing here' rather than crash on a quiet capture.""" @@ -177,6 +188,25 @@ def test_devices_are_listed_with_mac_and_id(self): self.assertIn('00:16:42:fb:04:2c', text) self.assertIn('id=566604', text) + def test_a_device_getting_no_frames_is_called_out(self): + # Seen on the show machine: 169.254.45.4 announced itself but BEYOND + # never streamed to it, so that projector stayed dark. + announce = bytes(0x20) + b'FB4E' + (566604).to_bytes(4, 'little') + packets = [ + udp(announce, sport=FB4_DISCOVERY_PORT, dport=FB4_DISCOVERY_PORT, + src='169.254.53.5'), + tcp(message(STREAM_TYPE_FRAME, bytes(16), 1)), + ] + self.assertIn('no frame stream', self.render(report_devices, packets)) + + def test_a_device_that_is_streaming_is_not_called_out(self): + announce = bytes(0x20) + b'FB4E' + (566604).to_bytes(4, 'little') + packets = [ + udp(announce, sport=FB4_DISCOVERY_PORT, dport=FB4_DISCOVERY_PORT), + tcp(message(STREAM_TYPE_FRAME, bytes(16), 1)), + ] + self.assertNotIn('no frame stream', self.render(report_devices, packets)) + def test_zone_state_is_the_last_value_seen(self): packets = [ udp(b'ControlZone 2\r\nRGBA 0, 10\r\n', sport=5000,