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,