Reading live sensor data from a SwarmTV Apple TV
For anyone who wants the room's heart rate, power, cadence and speed out of a SwarmTV. No NPE software on your side: a WebSocket library and a JSON parser are enough.
1What it is
Every SwarmTV Apple TV listens to the studio's WASP receivers, which pick up the ANT+ sensors in the room: heart-rate straps, power meters, speed and cadence sensors, CORE body-temperature sensors, and ANT+ FE-C fitness equipment. With the Aggregator setting on, the TV republishes every reading it hears as a small JSON document over a WebSocket server that the TV hosts itself.
The format is the WaspAggregator 1.1 format, the same one NPE's earlier Raspberry Pi aggregator produced. A client written for the Pi connects to the TV without changes. The differences that do exist are all fixes, and they are listed in section 9.
Things to know before you start:
- The TV is the server. You are the client. You connect to it and it pushes data to you. You never send it anything.
- One reading, one message. Each ANT+ page the WASP hears becomes one WebSocket text frame. A heart-rate strap gives you about four messages a second.
- One server per TV. A TV serves what its WASPs hear. A room with several TVs has several servers, usually hearing the same sensors. Section 8 covers that.
-
Plain
ws://, no login. It is unencrypted and unauthenticated by design, like the Pi. Section 11 says what that means for you. - Off by default. Someone has to turn it on per TV, or push the setting by MDM. Section 3.
2Quick start
- Turn Aggregator on for the TV (section 3).
-
Find the TV's IP address, or browse Bonjour for
_waspgator._tcp(section 4). -
Open a WebSocket to
ws://<tv-ip>:80/. -
Parse each text frame as JSON. Take
sensors[0]. Identify the sensor bydevice_typeplusdevice_number. Read what you need out ofsensor_data. - Age sensors out yourself. There is no "sensor gone" message.
Ten lines of Python that print heart rates, using the
websockets package:
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://192.168.0.70:80/") as ws:
async for text in ws:
s = json.loads(text)["1.1"]["sensors"][0]
hr = s["sensor_data"].get("heartrate")
if hr and hr["valid"]:
print(s["device_number"], hr["value"], hr["symbol"])
asyncio.run(main())
Section 12 has a complete JavaScript client with reconnect and age-out.
3Turning it on
One switch, off by default, in the TV's dashboard under TV Config:
| Setting | What it does |
|---|---|
| Aggregator | Starts the WebSocket server on the dashboard port and begins publishing. |
Three ways to set it:
- The TV's dashboard (the web page the TV serves on its own port 80). Flip the switch; the TV restarts its web server, which takes well under a second, and the aggregator is live.
-
MDM managed configuration. Push an app configuration
dictionary with the boolean key
AggregatorEnabled. An MDM value wins over the dashboard setting when the app launches, so this is how to turn it on for a whole fleet. - SwarmConnect, through the relayed dashboard. The same dashboard page, opened from SwarmConnect's console, reaches the TV over the cloud relay and writes the same key, so an operator can flip the switch remotely and it takes effect at once. There is no server-side setting behind it: nothing in the cloud pushes this key on its own, so fleet-wide control is MDM. Either way it is a studio-side action, not something your client can do.
The dashboard's WASP Info group shows an
Aggregator status row. It reads Off,
or On | subscribers N | sent N followed by extra counters
only when they are non-zero (dropped, shed,
skipped), so you can see from the TV's side whether
your client is connected and whether messages are going out. It does
not show the port.
4Finding the TV
Two options.
Bonjour. The TV advertises the service type
_waspgator._tcp on local. with the instance
name Wasp Aggregator, exactly as the Pi did. The
record carries the port, so a client that browses does not have to
guess between 80 and 8080.
dns-sd -B _waspgator._tcp # list every aggregator on the network
dns-sd -L "Wasp Aggregator" _waspgator._tcp local. # resolve one to host and port
The TXT record carries one entry,
company=North Pole Engineering, kept for parity with
the Pi. It holds nothing else, so there is nothing in it you need
to parse. If several TVs are on, several instances appear; Bonjour
makes the names unique for you.
A fixed address. Studio TVs are usually on wired Ethernet with a reserved DHCP address. Ask the studio for it.
5The connection
| Protocol | WebSocket, RFC 6455, over plain TCP. No TLS. |
| URL |
ws://<tv-ip>:80/.
Any path is accepted; /
and /ws are both fine.
|
| Port |
80. If 80 is already taken when the TV's web server
starts, the TV uses 8080 instead.
The Bonjour record carries the port that actually
bound; the Aggregator status row does not show it.
The aggregator shares the dashboard's web server,
so if the TV's dashboard itself opens at
http://<tv-ip>:8080, the aggregator
is on 8080 too.
|
| Subprotocol | None. Do not request one. |
| Authentication | None. |
| Direction | Server to client only. Anything you send is read and thrown away. |
| Frames | One JSON document per text frame. Never binary, never fragmented, never more than one document per frame. |
| Clients | Up to 16 at once. A 17th is accepted and immediately closed with code 1013 (try again later). |
| Keep-alive | The TV pings you every 30 seconds, and sooner if it has had nothing to send you for 20 seconds. Answer each ping with a pong. If the TV has received nothing at all from you for 90 seconds (it will have pinged you twice by then), it closes the socket with code 1001. Every mainstream WebSocket library, and every browser, answers pings automatically, so normally you never think about this. |
| Closes | The socket also closes when someone turns Aggregator off or restarts the dashboard server. Reconnect with backoff. |
A raw handshake, for people writing their own client:
GET / HTTP/1.1
Host: 192.168.0.70
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <16 random bytes, base64>
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <per RFC 6455>
Messages start arriving as soon as the upgrade completes, provided a sensor is in range.
6The message
One document per message, no whitespace, always this shape:
{"1.1":{"sensors":[{
"device_type":"heartrate",
"rssi":-40,
"device_number":34162,
"manufacturer_id":83,
"serial_number":3840968050,
"wasp_address":"192.168.0.25",
"timestamp":1788982155828,
"packets_per_second":6.25,
"sensor_data":{"heartrate":{"value":65,"symbol":"BPM","valid":true}}
}]}}
6.1 Envelope
-
The root key is the format version, currently
"1.1". Read it withObject.keys(root)[0]rather than hard-coding it. -
sensorsis an array that always holds exactly one element. It is an array only because the Pi's format said so.
6.2 Header fields
Always present, always in this order.
| Field | Type | Meaning |
|---|---|---|
device_type
|
string | One of the twelve values in section 7. |
rssi
|
integer |
Signal strength in dBm of the packet that produced
this message, as measured by the WASP. Typically
−20 (very close) to −90 (far). 0 when
the WASP did not report one; do not read it as a
strong signal.
|
device_number
|
integer |
The ANT+ device number. With device_type,
this is the sensor's identity.
|
manufacturer_id
|
integer | ANT+ manufacturer ID (for example 83 is Scosche). 0 until the sensor has sent its manufacturer page, which can take a few seconds after it appears, and which some sensors never send. |
serial_number
|
integer, unsigned 32-bit | The sensor's serial number, same caveat: 0 until known. |
wasp_address
|
string |
IP address of the WASP that heard the packet.
0.0.0.0 if unknown.
|
timestamp
|
integer | Milliseconds since 1970-01-01 UTC, set by the TV when the packet arrived. |
packets_per_second
|
number | The receive rate the TV currently measures for this sensor. |
sensor_data
|
object |
The readings. Keys depend on device_type.
Section 7.
|
6.3 Value nodes
Every entry in sensor_data is an object with a
value and a symbol (the unit label), and
on some keys a valid flag:
"heartrate":{"value":65,"symbol":"BPM","valid":true}
"cadence":{"value":88.0229,"symbol":"RPM"}
"fe_state":{"value":"InUse","symbol":"none"}
-
valueis a number or, forfe_stateonly, a string. Numbers may be integers (65), four-decimal fixed (88.0229,0.0000), or shortest-form decimals (38,37.9). Parse every numericvalueas a floating-point number, never as an integer. -
symbolis a label for humans and is fixed for a givendevice_typeand key; the same key can carry a different label on another type (cadenceisRPMon a bike andStrides/Minuteon a treadmill;speediskm/hon a power meter's wheel-torque page andm/son a speed sensor). Do not switch on it; look the unit up in the section 7 table for the type you are reading. Two labels are known to be wrong and are kept for compatibility:positive_vertical_distanceon an elliptical says"W", andaccumulated_periodon a torque trainer says"Nm". -
valid, where present, is the sensor's own "this number means something" flag. When it isfalsethevalueis still a number and you should ignore it:0forheartrate,skin_tempandcore_temp;255(the ANT+ "no cadence" marker) for everycadencekey that carries the flag. -
Numbers always use
.as the decimal separator whatever the TV's locale.
7Device types and their keys
Twelve device_type values. Keys appear in the order
shown. A key that has no source is omitted, never
sent as zero, so look keys up by name and treat missing as "not available".
"Fixed-4" means a number always written with four decimals; "decimal"
means shortest form.
heartrate
| Key | Value | Unit | Notes |
|---|---|---|---|
heartrate
|
integer |
BPM
|
valid is false when the
strap reports 0.
|
bike_power
Power meters send several ANT+ pages. Two of them produce messages; the rest (calibration, crank torque, manufacturer pages) produce none. Tell them apart by which keys are present.
Power-only page (the common one):
| Key | Value | Unit | Notes |
|---|---|---|---|
cadence
|
integer |
RPM
|
valid is false when the
meter has no cadence (raw 255).
|
power
|
integer |
W
|
Instantaneous power. |
accumulated_power
|
integer |
W
|
The meter's cumulative power counter. |
accumulated_event_count
|
integer |
Events
|
The meter's event counter. |
Wheel-torque page:
| Key | Value | Unit | Notes |
|---|---|---|---|
cadence
|
integer |
RPM
|
valid as above.
|
distance
|
fixed-4 |
m
|
Cumulative distance. |
speed
|
fixed-4 |
km/h
|
Kilometres per hour on this page, unlike the speed sensors below. The Pi did the same. |
accumulated_event_count
|
integer |
Events
|
bike_cadence
| Key | Value | Unit | Notes |
|---|---|---|---|
cadence
|
fixed-4 |
RPM
|
|
accumulated_cadence_revolutions
|
integer |
Events
|
Cumulative crank revolutions. |
bike_speed
| Key | Value | Unit | Notes |
|---|---|---|---|
speed
|
fixed-4 |
m/s
|
Metres per second, computed on a wheel circumference of about 2.07 m. |
accumulated_speed_revolutions
|
integer |
Events
|
Cumulative wheel revolutions. Multiply by your own circumference if you need distance. |
bike_speed_cadence
| Key | Value | Unit | Notes |
|---|---|---|---|
cadence
|
integer |
RPM
|
|
speed
|
fixed-4 |
m/s
|
As bike_speed.
|
accumulated_cadence_revolutions
|
integer |
Events
|
|
accumulated_speed_revolutions
|
integer |
Events
|
core_body_temp
| Key | Value | Unit | Notes |
|---|---|---|---|
data_quality
|
integer |
"" (empty)
|
The sensor's quality index. 255 when unknown. |
skin_temp
|
decimal |
C
|
valid is false while the
sensor is warming up; the value is then 0.
|
core_temp
|
decimal |
C
|
Same. |
fitness_equipment_treadmill
| Key | Value | Unit | Notes |
|---|---|---|---|
cadence
|
integer |
Strides/Minute
|
valid false when unknown (raw 255).
|
negative_vertical_distance
|
fixed-4 |
M
|
Cumulative. |
positive_vertical_distance
|
fixed-4 |
M
|
Cumulative. |
fe_state
|
string |
none
|
See fe_state below. |
fitness_equipment_elliptical
| Key | Value | Unit | Notes |
|---|---|---|---|
cadence
|
integer |
Strides/Minute
|
valid is false when unknown;
the value is then 255.
|
positive_vertical_distance
|
fixed-4 |
W
|
Label is wrong on purpose (kept from the Pi). It is metres. |
stride_count
|
fixed-4 |
Strides
|
Cumulative. |
power
|
integer |
W
|
|
fe_state
|
string |
none
|
fitness_equipment_rower
| Key | Value | Unit | Notes |
|---|---|---|---|
cadence
|
integer |
Strokes/Minute
|
valid is false when unknown;
the value is then 255.
|
stroke_count
|
fixed-4 |
Strokes
|
Cumulative. |
power
|
integer |
W
|
|
fe_state
|
string |
none
|
fitness_equipment_climber
| Key | Value | Unit | Notes |
|---|---|---|---|
cadence
|
integer |
Cycles/Minute
|
valid is false when unknown;
the value is then 255.
|
cycle_count
|
fixed-4 |
Cycles
|
Cumulative. |
power
|
integer |
W
|
|
fe_state
|
string |
none
|
fitness_equipment_bike
An FE-C trainer or studio bike sending its general trainer page.
| Key | Value | Unit | Notes |
|---|---|---|---|
cadence
|
integer |
RPM
|
valid is false when unknown;
the value is then 255.
|
power
|
integer |
W
|
|
accumulated_power
|
integer |
W
|
|
accumulated_event_count
|
integer |
Events
|
|
fe_state
|
string |
none
|
fitness_equipment_torque
The same trainer sending its torque page.
| Key | Value | Unit | Notes |
|---|---|---|---|
wheel_ticks
|
integer |
Ticks
|
The trainer's wheel counter. |
accumulated_period
|
integer |
Nm
|
Label is wrong on purpose (kept from the Pi). |
accumulated_torque
|
integer |
Nm
|
|
accumulated_event_count
|
integer |
Events
|
|
fe_state
|
string |
none
|
fe_state is one of Asleep,
Ready, InUse, Finished,
Reserved.
About the cumulative counters. Every
accumulated_*, *_count,
*_distance, and wheel_ticks value is
a running total. Use the difference between two consecutive
messages, not the absolute number. Expect the total to jump back
to a small number when a sensor restarts or the TV reconnects
to it, and treat any decrease as a reset.
8Timing, rates, and identity
-
Rate. You get one message per ANT+ page received.
Most sensors transmit four times a second; some heart-rate straps
and power meters do eight.
packets_per_secondtells you what the TV is actually seeing. -
Identity. A sensor is
device_type+device_number. Do not usewasp_addressorrssias part of the identity; they change from packet to packet. -
Several WASPs, one sensor. If two WASPs in the
room hear the same strap, you get each page once per WASP, with
alternating
wasp_address. Keying your state ondevice_type+device_numbermerges them for free; take the newest message and ignore the duplicates. - Several TVs. Each TV is an independent server with its own view of the room. Connect to every TV you care about and merge on the same identity. If you only need "the room", one TV is usually enough because every TV's WASPs hear every sensor.
- No dropout message. When a strap leaves the room the messages just stop. Keep a "last seen" time per sensor and expire it yourself. Six seconds is a sensible default.
-
Slow first fields.
manufacturer_idandserial_numberare0until the sensor has sent the pages that carry them. Do not treat0as an error and do not key on them.
9Differences from the Raspberry Pi aggregator
If you already have a client for the Pi, these are the only things that changed. Each is deliberate.
-
timestampis real Unix time in UTC milliseconds. The Pi used local wall time, so it was off by the time zone and jumped at daylight-saving changes. If your client compensated for that, stop. -
Decimal numbers always use
.. The Pi on a comma-decimal system emitted2,5, which is not JSON. -
Numeric
values are always real numbers, never a sentinel. An invalid CORE reading is{"value":0,"symbol":"C","valid":false}for both temperatures. The Pi leaked a raw sentinel of102.4for skin temperature. (fe_statestays a string, as on the Pi.) -
A few keys the Pi sent are gone, because the
TV's sensor stack has no source for them:
cadence_event_time,speed_event_time, andaccumulated_event_counton the speed and cadence types;statuson a trainer;transmission_infoon CORE. Missing means missing. Parse by key name, not by count or position. -
rssiis per packet and on one scale for every type. The Pi halved the CORE value. - One server per screen, not one per studio. See section 8.
- Sixteen clients instead of one. On the Pi a second connection hung forever.
-
wasp_addressandrssiare advisory. They come from the packet that produced the message in the normal case; under heavy load in a room with several WASPs a small fraction may belong to the next packet from the same sensor.
Everything else, including the envelope, key order, value-node shapes, the four-decimal formatting, and the Pi's two mislabelled units, is reproduced so an existing client keeps working.
10Troubleshooting
| Symptom | Likely cause |
|---|---|
| Connection refused on port 80 | Aggregator is off, or the TV fell back to 8080. Check the Bonjour record, or whether the TV's dashboard itself opens on 8080. |
| Connected, but no messages |
No sensor in range of the TV's WASPs, or the WASPs
are on a different network from the TV. The status
row's sent counter tells you whether
the TV is publishing at all.
|
| Closed immediately with code 1013 | Sixteen clients are already connected. |
| Closed with code 1001 after about 90 seconds, every time | Your client is not answering the TV's pings. Enable automatic pong in your library (hand-rolled clients: reply to opcode 9 with opcode 10 and the same payload). |
| Socket drops now and then | Someone toggled the setting or the dashboard server restarted. Reconnect with backoff. |
speed looks 3.6× too big or too small
|
Check which type you are reading. bike_speed
and bike_speed_cadence are m/s; the
power meter's wheel-torque page is km/h.
|
manufacturer_id and serial_number
are 0
|
Normal for the first seconds, and permanent for some sensors. |
11Security
The feed is unencrypted and unauthenticated, exactly as the Pi's was. Anyone on the studio network can read live heart rate and power keyed by stable device numbers. That is why it ships off, why turning it on is a deliberate act per TV or per fleet, and why the dashboard shows how many clients are connected. Run it only on a network the studio controls, and do not bridge it to the internet without putting your own authenticated service in front of it.
12Reference client (JavaScript)
Forty lines, no dependencies, with reconnect and age-out. Works in a browser or in Node 22+.
const url = "ws://192.168.0.70:80/";
let ws, backoff = 1000;
const sensors = new Map(); // "type;number" - { seen, data }
function connect() {
ws = new WebSocket(url);
ws.onopen = () = { backoff = 1000; console.log("connected"); };
ws.onmessage = (e) = {
const root = JSON.parse(e.data);
const version = Object.keys(root)[0]; // "1.1" — do not hard-code it
for (const s of root[version].sensors) {
const id = `${s.device_type};${s.device_number}`;
sensors.set(id, { seen: Date.now(), data: s.sensor_data });
const hr = s.sensor_data.heartrate;
if (hr && hr.valid) console.log(id, hr.value, hr.symbol);
}
};
ws.onclose = () = { setTimeout(connect, backoff); backoff = Math.min(backoff * 2, 30000); };
ws.onerror = () = ws.close();
}
setInterval(() = { // no dropout event: age sensors out yourself
const cutoff = Date.now() - 6000;
for (const [id, s] of sensors) if (s.seen < cutoff) { sensors.delete(id); console.log("gone", id); }
}, 1000);
connect();
AReal messages
Captured from an Apple TV on 2026-09-09. The heart-rate message is
a real Scosche strap through a real WASP; the others are simulated
sensors with known values (88 RPM, 5.0 m/s, 76 RPM + 7.5 m/s, and
a power meter), which is why manufacturer_id is 0 on
all of them and serial_number is 0 on three of them
(the simulated power meter reports serial 12345).
{"1.1":{"sensors":[{"device_type":"heartrate","rssi":-40,"device_number":34162,"manufacturer_id":83,"serial_number":3840968050,"wasp_address":"192.168.0.25","timestamp":1788982155828,"packets_per_second":6.25,"sensor_data":{"heartrate":{"value":65,"symbol":"BPM","valid":true}}}]}}
{"1.1":{"sensors":[{"device_type":"bike_power","rssi":-6,"device_number":12345,"manufacturer_id":0,"serial_number":12345,"wasp_address":"192.168.0.134","timestamp":1788982155915,"packets_per_second":8,"sensor_data":{"cadence":{"value":43,"symbol":"RPM","valid":true},"power":{"value":101,"symbol":"W"},"accumulated_power":{"value":16477,"symbol":"W"},"accumulated_event_count":{"value":107,"symbol":"Events"}}}]}}
{"1.1":{"sensors":[{"device_type":"bike_cadence","rssi":-6,"device_number":20003,"manufacturer_id":0,"serial_number":0,"wasp_address":"192.168.0.134","timestamp":1788982155914,"packets_per_second":8,"sensor_data":{"cadence":{"value":88.0229,"symbol":"RPM"},"accumulated_cadence_revolutions":{"value":37,"symbol":"Events"}}}]}}
{"1.1":{"sensors":[{"device_type":"bike_speed","rssi":-6,"device_number":20005,"manufacturer_id":0,"serial_number":0,"wasp_address":"192.168.0.134","timestamp":1788982155914,"packets_per_second":8,"sensor_data":{"speed":{"value":4.9335,"symbol":"m/s"},"accumulated_speed_revolutions":{"value":60,"symbol":"Events"}}}]}}
{"1.1":{"sensors":[{"device_type":"bike_speed_cadence","rssi":-6,"device_number":20004,"manufacturer_id":0,"serial_number":0,"wasp_address":"192.168.0.134","timestamp":1788982155914,"packets_per_second":8,"sensor_data":{"cadence":{"value":76,"symbol":"RPM"},"speed":{"value":7.3830,"symbol":"m/s"},"accumulated_cadence_revolutions":{"value":32,"symbol":"Events"},"accumulated_speed_revolutions":{"value":92,"symbol":"Events"}}}]}}
BWhere the details live
For NPE engineers, the sources of truth in the swarm
repository on branch rebuild:
-
docs/aggregator-integration-guide.md, the Markdown original of this page. -
docs/swarmtv-sensor-egress-contract.md, the short client contract this guide expands on. -
docs/waspaggregator-integration-plan.md, the byte-level reference for the Pi's original format. -
Rebuild/SwarmTV/Services/SensorEgress/AggregatorEncoder.swift, the encoder; what it writes is the format. -
Rebuild/SwarmTV/Services/SensorIO/AggregatorFrameBuilder.swift, which keys each device type carries. -
Rebuild/scripts/aggregator-watch.py, a terminal client that tabulates the feed. Handy for a first look:python3 aggregator-watch.py <tv-ip>.
Comments
0 comments
Please sign in to leave a comment.