Skip to content

lachlanmcalpine/colmi-ring

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Colmi R02 — what a $20 smart ring will actually give you

Byte-patched firmware for a cheap BLE smart ring, a measured accelerometer ceiling that contradicts its own datasheet, a typing decoder built on top of it, and a firmware reverse-engineering job on a ring that lied about its own hardware. Most of what's here is the record of being wrong in specific, instructive ways and fixing it.

Headline: the real distinct-sample ceiling is 352 Hz unfiltered / 270 Hz filtered — established using the sensor's own NEW-data bit rather than counting packets. The datasheet implies 2 kHz. It isn't there, and this repo documents exactly why. On top of that stream, a whole-word decoder reaches 95% top-5 / 86% top-1 word accuracy on coherent text from a single ring — and I can show precisely why the remaining error is the sensor's limit, not the model's.


Why I built it

AR glasses have a text-input problem nobody has solved. Every answer so far either bolts a keyboard back on — which defeats the entire point of not carrying a device — or asks you to talk to your glasses in public. I wanted to know whether a ring could do it instead: minimal form factor, hands where they already are, no surface required.

So the question that started this was narrow. Does a $20 smart ring contain enough signal to type with? Everything below is the work of answering it.


The problem

Consumer wearables expose a heavily filtered, heavily downsampled trickle of motion data, because that's all a step counter needs. If you want anything that depends on fine motion — gesture control, or reading finger taps — the published interface is nowhere near enough, and the vendor has no reason to give you more. So: how much signal is actually in the hardware, how much can you get out, and how much of that is enough to type with?


What went wrong, and what I changed

This is the honest part, and it's most of the project. Four separate arcs, each one a case of a comfortable assumption hiding the real problem.

1. I got the sample rate wrong three times

I reported 936 Hz, then 1650 Hz, then 440 Hz. All three were wrong. Every one was an artifact of counting packets or callbacks rather than distinct samples — the BLE walker was handing me the same reading more than once, and I was diligently measuring my own duplicates. An earlier "100 Hz" figure was a different bug: a slow FIFO-read loop, not a hardware limit.

What fixed it was finding a ground truth the sensor couldn't lie about. The accelerometer exposes a NEW-data bit at register 0x02 bit 0. Polling that instead of trusting packet arrival gives a number that can't be inflated by re-reads. That's where 352 Hz came from, and it's why I trust it when I didn't trust the previous three. The single-send poll also proves the ceiling rather than observing it: if the hardware were faster, the loop would find its ten fresh samples sooner, fire sooner, and deliver more. It doesn't.

I killed the 2 kHz chase after four failed fixes. The datasheet says the unfiltered ADC runs at 2 kHz; the STK8BA50 Linux driver independently implies 1792. Both say the speed is there. I tried four ways to make the FIFO accumulate — it stays in bypass with count = 1 every time. I wrote concluded / dead in the notes and stopped. Two documents agreeing doesn't outrank one measurement of the part in your hand, and knowing when to stop paying for a hypothesis was the more useful skill.

2. Teacher forcing hid the real blocker in the decoder

I chased end-to-end accuracy for a long time before realising that feeding ground-truth word boundaries during evaluation made segmentation look solved. It isn't — about 20% of words get cut in the wrong place, and that, not classification, is what caps the system. A convenience in the evaluation harness was flattering me for months.

3. The decoder's ceiling is the sensor, not the model — proved three ways

After getting coherent-text top-5 to ~95%, the obvious next move was to squeeze top-1 (the silently-committed word). I had three software theories. All three were wrong, and each null tightened the same conclusion instead of weakening it:

  • A better language model does nothing. I swapped 2019-era GPT-2 for a modern small LM (SmolLM2-360M) and benchmarked it on the same cross-validated harness. Zero top-1 gain. The decode is ring-limited, not LM-limited — the model already breaks every tie it can; the rest is the sensor not distinguishing the keys.
  • You can't insert your way out of it. The residual errors include dropped short words, so I built a pass that inserts function words the sentence "wants." Net-negative at every threshold, and worse with more context — because a language model optimises fluency, not fidelity. It happily inserts words that read well but weren't typed. Fidelity has to come from the tap, never from the model.
  • The decode parameters were already optimal. Re-tuning the length penalty for top-1 (not top-5) found the current settings win on both — tap counts are noisy in both directions, so the flex window is load-bearing and tightening it breaks more than it fixes.

A tap-by-tap autopsy of the surviving misses closed it: they're detection-amplitude failures — soft taps after a thinking-pause, on outer fingers physically far from the ring — not model failures. So I stopped optimising the model.

4. The variant that lied about its own hardware

A tighter-fitting ring arrived reporting hardware RT12COL_V1.0 — a string neither of my working rings used. The obvious read: new silicon, and my firmware (byte-patched against the RT02CR image) would brick it. I burned hours down an acquisition rabbit hole trying to get the stock image to reverse-engineer — found the vendor OTA server (predictable unauth URL), hit a signed/token-auth wall on the version-check API, and even pulled the vendor Android APK to read the endpoint out of its bytecode. All of it was wasted.

The thing that settled it was a one-flash test I'd been overthinking. My images are complete firmware, not patches — so flashing one just asks "does this chip run RT02CR firmware?" It did. The ring rebooted reporting RT02CR_V3.1, byte-identical to a known-good ring. "RT12COL" was a cosmetic firmware label on identical RTL8762E silicon. The direct empirical test beat the elaborate workaround — I'd been too cautious, and the "same ring, different label" instinct was right.

Then the sensor wouldn't stream — raw-motion-on returned an error frame, not data. Disassembling the init (fwdis.py) showed it's a chain of I2C register writes with no chip-ID check — it fails purely on missing ACKs. The accelerometer on this board simply doesn't answer at the device address the firmware expects, and retargeting to the two canonical STK8321 addresses didn't fix it either. So it's not a simple address swap; the part is on a genuinely different I2C path (power, bus/pins, or a swapped chip).

To find it definitively I wrote an in-firmware I2C address sweep — and bricked the ring, because I let the probe transmit its report at boot, before any BLE connection existed, which corrupted the GATT server past OTA recovery. That's a real failure and it's logged as one: an injection must never fire before a connection exists. The board was bought and colour-coded as a sacrificial unit for exactly this experiment, and it did its job — but the mistake is mine, and the boot-safe design (store the scan result, piggyback it on the command reply so it only transmits while connected) is written down for next time.


Architecture, and the calls I made

Firmware

  • Patch the firmware, don't fight the app. The vendor protocol is a dead end for rate. firmware/ holds the disassembly notes, the reproducible patch builder (build.py), a verifier, and the built images. Every image asserts its own known-good SHA on build — I never keep a binary I can't regenerate.
  • DATA_SEL over PROTECT_DIS. Register 0x13 bit 7 selects the unfiltered path and buys ~30% (270 → 352). Bit 6 disables register protection and looks like it should help — it makes things worse (211, with torn reads). Documented because a plausible idea that fails is worth keeping.
  • Small-batch notify over max throughput. The flagship build sends 4 samples per notification (~90 notifications/sec, ~11 ms latency) with a rolling drop counter at pkt[63], so packet loss is visible rather than silently degrading the decode. Observable latency over an unverifiable peak number.
  • Cortex-M0, so no movw/movt/add.w. The RTL8762E is ARMv6-M — every injected constant is built from movs/lsls/adds, and the builder's m0_scan rejects any M0-unsafe instruction automatically. This one fact caused every early crash.

Decoder

  • The decoder is LM-driven and ring-assisted, not the other way round. Ring signal alone gets 3–30% top-1. Being straight about that ratio is what stops the project overclaiming — and it's what predicted, correctly, that a bigger model wouldn't help.
  • Whole-word DP-align decode. It scores the tap sequence against a 50k dictionary, so it structurally cannot misspell — errors are wrong-word, never garbled-word. Split- and weak-space-merge recovery passes fix the mis-cut boundaries that are the real ceiling.
  • A calibrated confidence score is the product. Since the residual error is the sensor's, not the model's, the answer isn't a better decoder — it's an autocorrect-shaped UX driven by confidence: silently commit when sure, offer the top-5 when not, flag "check this" when unsure. I verified the score is calibrated — accuracy climbs monotonically with it — so the 4–5% that stays wrong can be flagged rather than silently wrong.
  • Universal vs personal. The decoder splits cleanly: the language model transfers for free across users and surfaces (and is the dominant half), while onset detection and which-key are personal and surface-specific. That decomposition is the whole commercialisation story — a base model plus a few minutes of per-user calibration, where more users make onboarding cheaper.

Claude wrote most of the code here. The engineering judgment is mine — trusting the sensor's NEW-data bit over the transport layer, knowing when to stop paying for the 2 kHz and insertion hypotheses, reading "same ring, different label" off the hardware strings, and choosing to redesign around a confidence score once the ceiling was proven to be the sensor.


Results

Sample rate — what I claimed, and what was true:

claim method verdict
936 Hz counting callbacks wrong — BLE walker re-reads
1650 Hz counting packets wrong — duplicates
440 Hz packet timing wrong — same cause
100 Hz FIFO read loop wrong — slow loop, not a hardware limit
270 Hz NEW-data bit, filtered path correct
352 Hz NEW-data bit, DATA_SEL unfiltered correct — the ceiling

Typing decode — end-to-end top-5, each rung a separate, measured change:

step @5
baseline 64.5
proper split + 5-fold CV 70.7
data cleaning 73.5
weak-space merge 76.7
length penalty (λ=4) 79.0
which-key CNN tuning 81.3
bidirectional sentence re-rank 85.0

Reported on all words, that final config is @1 72 / @5 85 / @10 87 (3-fold CV, 219 words). But ~28% of the test text was deliberately-typed gibberish (names, out-of-context words); on coherent text — what you'd actually write — it's @1 86 / @5 95.5 / @10 95.5. That coherent number is the deployment-relevant one, and top-5 == top-10 (a zero gap) proves the residual miss is words the ring never proposed at all — a detection failure, not a ranking one.

Rate helps, but not uniformly: 25 Hz → 352 Hz lifts which-key precision +43% and finger +34%, and moves row/hand accuracy essentially not at all. Rate is banked as necessary but not sufficient.


What I learned, and what's next

  1. Measure the thing, not a proxy for the thing. Four wrong rate numbers came from counting deliveries instead of distinct samples.
  2. Two documents agreeing doesn't outrank one measurement. The datasheet and the kernel driver were both consistent with 2 kHz and both irrelevant to the part on my finger.
  3. A convenience in evaluation can hide your real bottleneck for months. Teacher forcing made segmentation invisible.
  4. When three fixes all fail the same way, that's the answer. A bigger model, word insertion, and re-tuning all gave nothing — because the wall is the sensor, not the software. The productive move was to stop optimising and redesign around it.
  5. The direct empirical test usually beats the elaborate workaround. A one-flash "does it run?" answered in seconds what a multi-hour cloud-RE acquisition couldn't.
  6. Fail on the unit you bought to fail on, and write the failure down. The bricked ring cost nothing it wasn't meant to — and the boot-safe injection rule is now documented.

Next:

  • Segmentation / detection is the blocker, not classification or the model. Everything else is second.
  • The confidence-tiered UX (commit / offer / flag) is the phase-2 spine — it turns the residual 4–5% from silent errors into flagged ones.
  • Two rings for the hand-pattern separation a single ring can't provide.
  • Adaptive-onset detection to recover the soft, post-pause taps that are most of the miss.
  • Names and out-of-vocabulary words are unfixable in software here; the ring is too weak to spell — a deliberate character-entry mode is the escape hatch.

Layout

firmware/       patch builder + reproducible images, disassembly notes (fwdis.py), verifier,
                and the RT12COL variant tooling (build_addr.py, build_sweep.py — the sweep
                sends at boot, kept as a documented cautionary example, do not reuse as-is)
typing/         the decoder: features, models, cross-validated evaluation, confidence work
cockpit/        tilt + gesture radial-menu controller
autocomplete/   LM-assisted word prediction from ring selects
gestures/       gesture capture + models
boxing/         a punch-detection side experiment
*.py  (root)    core modules — ring_reader, type_*, single_ring, signal_decomp — alongside a
                large set of eval_*.py / reassess_*.py experiment scripts kept for the record

The root holds core modules and experiment scripts together; splitting the eval_*.py work into its own package is on the list.

About

Reverse-engineering the Colmi R02 smart ring: byte-patched firmware, a measured 352Hz accelerometer ceiling, and a ring-based typing decoder.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages