Moving My TENS Controller from a Laptop to a Phone
In the first post, I decompiled the FitRx Android app, worked out its Bluetooth protocol, and ended up with a Python script that drives the unit from my MacBook. It worked. It also meant doing quad sets next to a laptop, in a terminal, with Ctrl+C as the stop button.
So the controller is now a web page. Open it in Chrome on your phone, tap Connect, pick a routine, and go: jon.glass/fitrx.
This drives an electrical stimulator worn on your body. The unit’s power button, or pulling a pad, is the real emergency stop. Test with the pads off first. This isn’t a medical device, so follow your clinician’s guidance.
Why a web page and not an app
The obvious answer was an Android app. The irony is that the vendor’s own Android app is the reason this project exists: it wouldn’t install on my phone because the phone was “too new.”
Chrome on Android supports Web Bluetooth, so a page served over HTTPS can scan for the unit, connect, and write to the same characteristics the Python script uses. That means:
- One file. The whole thing is a single
index.htmlwith no build step, no server, and nothing to install. - No app store. It works on any phone running Chrome, and on desktop Chrome too.
- Nothing leaves the device. The page talks to the unit over Bluetooth and to nothing else.
Porting the protocol
The Python controller was already doing the hard part, so the port was mostly translation. The command frames are byte-for-byte the same as in the script:
// Commands, byte-for-byte the same as fitrx_session.py
const CMD = {
mode: m => `ACAD06A1000000${dec2(Math.max(1, Math.min(8, m)))}BCBD`,
level: l => `ACAD06A0000000${dec2(Math.max(0, Math.min(9, l)))}BCBD`,
timer: min => min === 0 ? "ACAD06A200000000BCBD" : `ACAD06A2010100${hex2(min)}BCBD`,
};
Mode is still clamped to 1–8 and level to 0–9, the same as in Python.
The one real difference: Web Bluetooth allows only one GATT operation at a time. Start a second write while the first is still in flight and the browser throws an error. So every write goes through a queue, with a short pause after each one:
function send(hexStr, label) {
const job = writeChain.then(async () => {
if (!writeChar || !device?.gatt?.connected) throw new Error("Not connected");
await writeChar.writeValueWithoutResponse(fromHex(hexStr));
await new Promise(r => setTimeout(r, 80));
});
writeChain = job.catch(() => {});
return job;
}
The session loop is the same shape as before: ramp up one level at a time, hold, ramp back down, rest, and repeat until the session time is up.
Presets instead of flags
The CLI flags became a form, and the two routines I actually use became one-tap presets:
| Preset | Peak level | Ramp up | Hold | Ramp down | Rest | Session |
|---|---|---|---|---|---|---|
| Pain relief | 3 | 90 s | 28 min | 10 s | none | 30 min |
| Quad sets | 4 | 2 s | 10 s | 2 s | 50 s | 15 min |
Changing any field switches to Custom. Every field is checked before a session starts, and sessions are limited to 5–40 minutes. That range is what I’m willing to hand the unit’s own timer as a backstop.
Safety, now on a phone
A phone is a worse place than a laptop to run something like this. Screens lock, browsers suspend tabs in the background, and Bluetooth drops when you walk away. So the page leans even harder on “fail toward off”:
- The hardware timer is set before anything else. The first thing a session does is send level 0 and set the unit’s timer to the session length, and only then the mode.
- I tested the backstop. With the pads off, I started a session and killed the controller. The unit shut itself off on schedule. Level 0 really is no output, and the stop sequence (level 0, then timer 0) leaves the unit quiet. Those were the two open questions from the first post. Both are answered now.
- The screen stays awake during a session (Screen Wake Lock), so the phone doesn’t lock mid-routine.
- Leaving the page sends a stop. Closing the tab or navigating away sends level 0 and turns the timer off.
- A dropped connection is loud. If Bluetooth disconnects mid-session, the page says so plainly and tells you to use the power button. It doesn’t quietly pretend nothing happened.
- Errors still end in a stop. Whatever goes wrong, the session’s
finallyblock sends level 0 if the connection is still up.
Stop, errors and leaving the page are the same three exits the Ctrl+C handler covered in Python, now in browser form.
What’s still open
- The unit’s notifications are still logged as raw bytes (
RX …in the page’s log), not decoded. - Modes are still numbered 1–8. I haven’t pulled the real names out of the app’s resources yet.
- Letting Claude start and adjust sessions is still on the list.
If you have a FitRx Wireless (HRK-016B) and Chrome, give it a try at jon.glass/fitrx. The source is a single readable file at github.com/glassdfir/fitrx.