jack hall

Case study · Mobile and embedded Linux

A phone app and a TV display for a radar sensor

An open-source radar sensor for sports ball tracking showed each shot on one small touchscreen. I gave it a phone app that works with no Wi-Fi, a TV display, and a LAN server that keeps other devices away from its settings.

Role
Open-source contributor, in my own fork: mobile app, Bluetooth LE link, display UI and server hardening
When
June to August 2026
Scope
React Native app, BLE GATT service with HMAC pairing, React display UI, Python server, flight model, performance pass

Context

The device is an open-source radar sensor for sports ball tracking. A single-board Linux computer reads a Doppler radar for speed and smaller radars for angle, and a Python server pushes each shot over a WebSocket to a React page on a small touchscreen beside the sensor.

Other contributors own the radar side: detection, spin and angle extraction. I worked on what sits around it. From June to August I made 104 commits in my own fork, across the phone app, the display, the server and the Bluetooth link between them.

Problem

You could see a shot in two places: the touchscreen, or a browser on the same network. Outdoors there is often no Wi-Fi you can join. Indoors you want the numbers on a TV across the room, driven by the TV's own remote.

Any new path in had to stay safe on a shared network. The server took commands that change radar settings and served a camera stream. A phone on the guest Wi-Fi should not be able to do either.

What I measured

Three hot paths

In August I timed code that runs per frame, per request or per page load, and changed three paths. The CFAR time is the median of 20 runs.

Before and after, per change
PathBeforeAfter
OS-CFAR noise estimate, 2,048-bin spectrum 26.4 ms 1.25 ms, same detections bit for bit
History scan of a 2.5 MB session file 26.3 ms 3.7 ms
Display UI main bundle 672 kB (205 kB gzip) 317 kB (100 kB gzip)

The CFAR loop built a fresh training array for every bin. A sliding-window view takes the median of every window in one call. The history scan parsed every line of every session file, including raw capture rows that carry 4,096 I and 4,096 Q samples each, and a substring test now skips those before the JSON parser sees them. The bundle carried 15 language catalogs that most users never load, so I moved them into on-demand chunks and kept English in the main bundle.

A flight model held to reference ranges

The server simulates each ball's flight. I rebuilt the model with published drag and lift coefficients that depend on spin, and a 1 ms RK4 step. Regression tests hold the simulated trajectories inside reference ranges for typical shots. I did not run a side-by-side comparison against another tracking system.

What I did not measure

I have no recorded numbers for the phone app on hardware: Bluetooth latency, reconnect success rate or battery cost. My manual test plan has Bluetooth and Wi-Fi cases, and their result rows are still empty.

What I changed

A phone app with two transports

I built a React Native app in TypeScript that runs Wi-Fi and Bluetooth LE at the same time. A WebSocket carries shots when the phone shares the sensor's network. A custom BLE GATT service carries them when it does not, and Wi-Fi wins when both are up. Screens read one merged connection, so none of them branches on transport.

Shots land in an encrypted SQLite store. The app keys each shot on several fields together, so two real shots with the same timestamp both persist. It buffers shots that arrive before the session row exists. On top of that store sit lifetime stats, trend charts, a dispersion chart, and a 3D trajectory view that runs the same flight model as the server.

Bluetooth pairing without sending the secret

I wrote the GATT peripheral on the sensor in Python and the client in the app. Pairing happens once: the touchscreen shows a QR code holding a 32-byte secret, and the server serves that code only to the device itself. On every connection the sensor publishes a fresh nonce, the phone answers with an HMAC-SHA256 of it, and the sensor checks the answer with a constant-time compare. Nonces rotate every 4 minutes and expire after 5.

The app's JavaScript engine ships no WebCrypto, so I wrote the HMAC in plain TypeScript and tested it against the RFC 4231 vectors. A shot over 512 bytes falls back to short field names before the sensor truncates it. A dropped link retries after 3, 6 and 12 seconds.

A display for the TV across the room

I redesigned the display UI around three view modes: the interactive touchscreen view, a passive scoreboard for a second screen, and a phone web remote that relays D-pad keys to the display. On a TV, a small Python service turns HDMI-CEC remote presses into arrow keys, and a USB IR receiver covers TVs without CEC. The launcher detects whether the screen is a TV or a monitor and picks the first view to match. A low-power flag drops the backdrop blur that the board's GPU struggles with.

The display UI has string catalogs for 16 languages. Controls carry ARIA roles and 44 px minimum targets, and they respect reduced motion. Reduce-motion, high-contrast and large-text settings set on the phone carry over to the display.

A review of the LAN surface

  • Radar settings changes now need the admin token. Before, any client on the network could send them.
  • The camera stream got its own token. Its URL carries the token, and that URL lands in access logs, so it must not unlock anything else.
  • The cross-origin allowlist works now. The server had handed the compiled LAN pattern to the CORS library as a literal string.
  • The pairing endpoint checks the socket's peer address. It had trusted X-Forwarded-For, which any host on the LAN can set.
  • The server binds to localhost by default, takes only known accessibility keys from clients, and refuses non-HTTPS URLs in the cloud uploader.

Test plans for what a test runner cannot reach

TV remote buses, IR receivers, screen readers and a large TV driven by a small board sit outside the automated test suites. I wrote 11 manual test plans for those paths. Each one names the hardware, the steps, what passing looks like, and the logs to attach on a failure.

Result

The fork now has a phone app that receives shots over Wi-Fi or Bluetooth, and a display that runs on a TV under its own remote. At my last commit in August 2026, 751 Python tests and 110 UI tests passed. The CFAR estimate runs about 21 times faster, the history scan about 7 times faster, and the main bundle is less than half its old size.

The phone and TV paths have no recorded hardware results yet, so I cannot claim numbers for them.

What I'd do differently

  • Review the network code before it ships. I had written the broken CORS allowlist and the forwarded-header check myself a few days before the review found them.
  • Keep build output out of git from the first commit. A stale Android JavaScript bundle checked into the repo hid every UI change from my debug builds until I traced it. Removing it later deleted about 176,000 lines.
  • Measure the Bluetooth link before building screens on it. Latency and reconnect numbers from a bench run would have told me whether the 512-byte fallback and the retry schedule were the right calls.