A Python async server that exposes the CSAFE interface on two Precor P80 consoles (an AMT and an RBK) over REST + WebSocket so a React Native app can discover the machines, stream live telemetry, and drive workouts.
Target hardware: Raspberry Pi 3B+ (Raspberry Pi OS, 64-bit).
┌──────────────┐ CSAFE / RS-232 ┌───────────────┐
│ Precor AMT │◀────────────────────────▶│ │
│ (P80 console)│ RJ45 → DB9 → USB │ Raspberry Pi │ Wi-Fi / LAN ┌──────────────┐
├──────────────┤ │ 3B+ │◀───────────────▶│ React Native │
│ Precor RBK │◀────────────────────────▶│ csafe-server│ REST + WS │ app │
│ (P80 console)│ │ :8080 │ └──────────────┘
└──────────────┘ └───────────────┘
You need to wire each machine's CSAFE port to a serial port on the Pi. CSAFE is standard RS-232 (not TTL, not USB), presented on an RJ45 jack labeled "CSAFE" on the machine's lower I/O board — not on the P80 console itself. The P80 is just the display head; the CSAFE link is exposed by a separate PCA in the machine's base, reachable through a service panel on the frame (under the rear cowl on the RBK, behind the lower service cover on the AMT).
| Qty | Item | Notes |
|---|---|---|
| 2 | CSAFE RJ45 → DB9 female cable | Sold as "CSAFE cable" or "Polar / Precor CSAFE adapter". Pre-wired for the CSAFE pinout below. |
| 2 | USB ↔ RS-232 adapter (FTDI FT232 based) | Genuine FTDI recommended; avoid PL2303 clones on the Pi. Provides /dev/ttyUSBx. |
| 1 | Powered USB hub (optional) | If you already use the Pi's USB for peripherals. |
| 1 | Raspberry Pi 3B+ with PSU and microSD (≥8 GB) | Running Raspberry Pi OS 64-bit (bookworm or later). |
Alternative (saves one USB port): use the Pi's built-in UART (/dev/serial0,
GPIO 14 TX / GPIO 15 RX) for one machine via a 3.3 V TTL ↔ RS-232 level
shifter (MAX3232 breakout). Do not wire RS-232 directly to the Pi GPIO —
you'll destroy the SoC. The default config assumes two USB adapters, which is
simpler and safer.
Source: pins 1–3, 6–8 are quoted from the CSAFE specification via [Wikipedia][csafe-wiki]. Pins 4–5 are inferred as the RS-232 data pair but pin order (which is TXD, which is RXD) is not confirmed from a public source. Verify against your Precor service manual before wiring.
| RJ45 pin | Signal (per CSAFE spec) | DB9 pin | Cat5e T-568B color |
|---|---|---|---|
| 1 | Audio Left Input | — | White/Orange |
| 2 | Audio Right Input | — | Orange |
| 3 | Voltage Source Output (4.75–10 V) | — | White/Green |
| 4 | Serial data [INFERRED: TXD out] | 2 (RXD) | Blue |
| 5 | Serial data [INFERRED: RXD in] | 3 (TXD) | White/Blue |
| 6 | CTS Flow Control Input | 7 (RTS) optional | Green |
| 7 | Signal Ground | 5 (GND) | White/Brown |
| 8 | Shield | shell | Brown |
Framing: 9600 8N1, no flow control required (CTS on pin 6 is optional).
⚠️ Pin 3 carries 4.75–10 V from the machine. This is a power supply output from the equipment intended to power a CSAFE master device. Do not connect it to an RS-232 adapter data pin or a 3.3 V UART. Leave it unconnected or insulate it.
On the RBK specifically, the lower I/O board is generator-powered — the CSAFE link won't come alive until you've pedaled for a few seconds and the console has booted.
sudo apt update
sudo apt install -y python3-venv python3-pip
git clone <this repo> && cd PRECOR
python3 -m venv .venv
source .venv/bin/activate
pip install -e .[dev]Add your user to dialout so the server can open /dev/ttyUSB* without sudo:
sudo usermod -aG dialout $USER
# log out / log in onceCheck the adapters:
ls -l /dev/serial/by-id/
# usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0 -> ../../ttyUSB0
# usb-FTDI_FT232R_USB_UART_A50285BK-if00-port0 -> ../../ttyUSB1Prefer the stable /dev/serial/by-id/... paths over /dev/ttyUSBx — the USB
enumeration order isn't guaranteed across reboots.
Create a .env in the repo root (optional — the defaults assume
/dev/ttyUSB0 for AMT and /dev/ttyUSB1 for RBK):
CSAFE_HOST=0.0.0.0
CSAFE_PORT=8080
CSAFE_TELEMETRY_INTERVAL=1.0
CSAFE_COMMAND_TIMEOUT=1.5
# Heartbeat when running headless (no P80). 0 disables.
CSAFE_KEEPALIVE_INTERVAL=0.0
CSAFE_DB_PATH=/var/lib/csafe/csafe.sqlite
# JSON-encoded list; use `/dev/serial/by-id/...` for stability.
CSAFE_MACHINES='[
{"id":"amt","kind":"amt","port":"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0","label":"Precor AMT 835"},
{"id":"rbk","kind":"rbk","port":"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BK-if00-port0","label":"Precor RBK 835"}
]'csafe-server # or: python -m csafe_server.mainServer listens on http://<pi-ip>:8080. OpenAPI docs at /docs.
To run on boot, drop this systemd unit at /etc/systemd/system/csafe-server.service:
[Unit]
Description=Precor CSAFE Gateway
After=network-online.target
[Service]
User=pi
WorkingDirectory=/home/pi/PRECOR
EnvironmentFile=/home/pi/PRECOR/.env
ExecStart=/home/pi/PRECOR/.venv/bin/csafe-server
Restart=on-failure
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now csafe-serverAll endpoints live under /api. JSON in/out.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/machines |
List configured machines and their current state. |
| GET | /api/machines/{id} |
One machine's info. |
| GET | /api/machines/{id}/telemetry |
Latest cached telemetry sample. |
| POST | /api/machines/{id}/workout/start |
Begin a session (body: {user_label?}). Returns session_id. |
| POST | /api/machines/{id}/workout/pause |
Send GOFINISHED to the console. |
| POST | /api/machines/{id}/workout/stop |
End and persist the session. |
| POST | /api/machines/{id}/resistance |
{level: 1..30} — maps to CSAFE SETGEAR. |
| POST | /api/machines/{id}/target-power |
{watts: 0..2000} — maps to SETPOWER. |
| POST | /api/machines/{id}/speed |
{kmh: 0..40} — maps to SETSPEED. |
| GET | /api/sessions?limit=50 |
Session history (most recent first). |
| GET | /healthz |
Liveness probe. |
curl -s http://pi.local:8080/api/machines | jq
curl -s -XPOST http://pi.local:8080/api/machines/rbk/workout/start \
-H 'content-type: application/json' -d '{"user_label":"ben"}'
curl -s -XPOST http://pi.local:8080/api/machines/rbk/target-power \
-H 'content-type: application/json' -d '{"watts":180}'Connect to ws://<pi>:8080/ws/machines/{id}/telemetry. Messages are JSON of
shape Telemetry:
{
"machine_id": "rbk",
"timestamp": "2026-04-15T12:34:56.789Z",
"state": "in_use",
"elapsed_sec": 322,
"distance_m": 1850.0,
"calories": 74,
"speed_kmh": 28.6,
"cadence_rpm": 89,
"heart_rate_bpm": 148,
"power_watts": 182,
"grade_pct": null
}React Native sketch:
const ws = new WebSocket(`ws://${PI_HOST}:8080/ws/machines/rbk/telemetry`);
ws.onmessage = (ev) => setTelemetry(JSON.parse(ev.data));csafe_server/
├── main.py FastAPI entrypoint + lifespan
├── config.py Settings + machine wiring table
├── models.py Pydantic DTOs shared by API + DB
├── manager.py MachineManager (owns machines, sessions)
├── machine.py Async per-machine serial + poll loop
├── db.py aiosqlite session + samples store
├── api/
│ ├── rest.py REST endpoints
│ └── ws.py WebSocket telemetry
└── csafe/
├── protocol.py Framing, byte-stuffing, checksum, FrameReader
└── commands.py Opcodes, encode_commands, parse_response
tests/
├── test_protocol.py Framing/stuffing/checksum (hardware-free)
└── test_commands.py Command encoding + response parsing
- Framing is implemented against the public CSAFE spec:
F0start (F1for extended),F2end,F3escape, payload + XOR checksum in between, with reserved bytes stuffed asF3 (b - 0xF0). - Telemetry opcodes used by the polling loop (
GETSTATUS,GETTWORK,GETHORIZONTAL,GETCALORIES,GETSPEED,GETCADENCE,GETHRCUR,GETPOWER) are standard CSAFE and have been observed to work on P80 firmwares. - Control opcodes (
SETGEAR,SETPOWER,SETSPEED) are standard CSAFE but vendor-specific in practice. The P80 accepts these on supported firmware, but exact units/ranges can differ. Verify against your console before wiring them to user-facing controls, and tweakcsafe_server/machine.pyif the scaling is off. - The P80's CSAFE state machine expects a sequence before it takes SET
commands:
RESET → GOREADY → GOINUSE.start_sessiondoes this for you. - If a console ignores control commands outright, check that the P80 is set to "CSAFE" (not "ADA") equipment mode in its service menu.
.venv/bin/pytest -q # 26 tests, hardware-free
.venv/bin/ruff check csafe_server testsThe protocol and parser layers have no hardware dependency, so CI on a laptop covers the risky bits. Integration testing requires the Pi + a live console.