Most of HealthyPi Move NEXT is software, and all of that software is a free over-the-air update to every HealthyPi Move that has ever shipped. If you’ve had one on your wrist since the first production run, everything in the first four sections below is yours — no new hardware, no new SKU, no charge.
That’s the part we want to lead with, because it’s the part that took the most work to make possible. The firmware crossed two major SDK versions during this cycle and we still held the flash layout identical so old watches could take the image. §1 is about how that was kept true.
There is also a hardware change: a redesigned optical front end on the sensor board, which is a board revision and therefore only exists on units built from here on. That’s §5, and it’s the last section on purpose — it’s the smaller half of the release by volume of work, and it’s the half most readers can’t install.
Scope of the release
| Subsystem | Change | Reaches |
|---|---|---|
| Firmware data layer | Custom BLE service → HPI_HS MCUmgr group; epoch aggregation; continuous HRV |
Every Move ever shipped, via OTA |
| On-watch UI | Full rewrite (tile carousel, watch faces, settings, POST screen) | Every Move ever shipped, via OTA |
| Companion app | v3.0, plus a standalone Dart protocol package | Play Store / App Store |
| PPG optical front end | Integrated module → discrete emitter/detector stack + opaque barrier and clear epoxy seal | New boards only (includes all pending Crowd Supply backorders) |
| nRF5340, 1.28″ AMOLED, MAX30001 ECG AFE, BMI323 IMU, enclosure, band, battery, finger sensor | Unchanged | — |
What this article covers
- The update that reaches every Move — the flash-map constraint, signing keys, and the OTA path
- The firmware data layer — MCUmgr transport, wire format, aggregation, HRV, robustness
- The on-watch interface
- The app and the Dart SDK
- The new optical front end — why it changed, the geometry, crosstalk control
- Availability and sources

1. The update that reaches every Move
Because the software half goes out as an OTA to watches already on people’s wrists, a lot of this cycle was decided for us.
1.1 Keeping the flash map byte-for-byte
The firmware crossed two major nRF Connect SDK versions this cycle — NCS 3.1 to 3.4, meaning Zephyr 4.4 and GCC 14 — and we retired Nordic’s Partition Manager in favour of defining the flash layout in the board devicetree. Either one is normally where you’d redraw the flash map, since you’re already elbow-deep in it.
We didn’t. The migration was constrained so the new flash map reproduces the old one byte-for-byte — same slot addresses, same signing chain, same MCUboot mode. Fiddly, unglamorous, and it buys exactly one thing: a watch from the first production run can take a NEXT image over the air and boot it.
1.2 Signing keys
The keys are in the repository on purpose. They’re development keys, which means any image you build yourself will be accepted by a stock watch — the bootloader’s trust anchor is the one we ship. If you’re building a product on Move, generate your own; the firmware README covers how, and why ours shouldn’t ship in anything commercial.
It’s a deliberate trade. On a developer device, being able to keep the thing alive yourself — with or without us, in ten years — beats secure-boot exclusivity.
1.3 The OTA path itself
The previous release shipped with the in-app updater still in beta, and pointed people at a manual DFU procedure through a separate Nordic app. That’s done with: updating now happens in the HealthyPi Move app. Three changes did most of the work, and they’re all about failing early and loudly rather than late and mysteriously:
- The app pre-flights the watch before sending a byte. It reads the device’s actual firmware slot map first, so a package whose image indices don’t match is rejected up front with a clear reason — instead of turning up as an assertion inside the update handler partway through an upload.
- The watch treats an update as a mode, not a background task. A dedicated update screen locks navigation, and sensor acquisition and background storage writers are quiesced for the duration. That matters here specifically because the health-store log and the landing firmware image share the same external QSPI die. There’s also a battery gate, so an update refuses to start rather than dying 80% of the way through.
- Link drops fast-fail. A Bluetooth disconnect fails immediately and a stalled transfer times out, so the update screen can’t sit forever on a progress bar that will never move.
2. The firmware data layer
This is the biggest single piece of work in NEXT, and the reason the watch now holds roughly three months of history.
2.1 Healthy Store on MCUmgr instead of a custom protocol
The old firmware moved health data over a custom BLE service with hand-rolled framing — start-of-frame 0x0A 0xFA, length field, command byte, stop bytes — and pulled sessions as whole files off the filesystem. It worked. It was also ours to debug, ours to version, and ours to keep reliable across every phone and every BLE stack, and every third-party client had to reimplement it from a README.
Meanwhile, sitting on the same radio, was a protocol we already trusted with the single most safety-critical thing the watch does: MCUmgr/SMP, the standard Zephyr management protocol that carries firmware updates. If SMP is reliable enough to stream a signed image into flash — where one corrupted byte bricks the device — it’s reliable enough to move heart-rate samples.
So NEXT deletes the custom command service and implements the Healthy Store (HPI_HS) as a custom MCUmgr management group, id 0x1000. The consequences are bigger than they sound:
- Reliability comes for free. Sequence matching, request/response pairing, framing, reassembly and error codes are SMP’s problem, exercised by everyone who has ever done a Zephyr DFU.
- Transport-agnostic by construction. BLE today; the identical command set runs over USB-CDC or UART the moment we re-enable the serial transport — a different pipe, not a different protocol.
- Any MCUmgr client works. The app uses our own Dart/Flutter SMP client,
mcumgr_dart, published on pub.dev; our reference research client is Pythonsmpclient. If you already have MCUmgr tooling, you already have most of a HealthyPi Move client. - One link, one lock. DFU, sync and record downloads share one characteristic and arbitrate against each other properly, instead of racing across separate custom services.
Requests and responses are CBOR maps, like every other MCUmgr group. Seven commands cover the whole surface: HELLO, TYPES, SYNC, SUMMARY, RECORDS, ACK, SET_TZ, plus blood-pressure calibration.
The engineering detail is its own post. How you actually extend MCUmgr in Zephyr — the handler table and the one macro that registers it, the netbuf constraint that sets the batch and paging limits, the cursor design that makes a dropped link cost nothing, and what the same approach looked like the second time around on HealthyPi 6 — is written up in Building a Custom MCUmgr Group in Zephyr.
2.2 Wire format and on-device type registry
A sample on the wire is an 18-byte packed record: monotonic seq, UTC timestamp, type id, quality bitmask, fixed-point value.
The important bit is that the type registry lives on the device, not in the client. Ask the watch for TYPES and it hands back the table: per metric id, its key, unit, scale divisor, whether it’s discrete, cumulative or an event, and even the Apple HealthKit and Android Health Connect types a bridge should map it to. Add a metric in firmware and old clients skip an id they don’t recognise, rather than crashing or — worse — silently mislabelling it.
The quality byte carries per-sample flags: timestamp validity, on-skin contact, IMU low-motion, algorithm confidence, whether the sample fell inside a detected sleep window, and whether it was manually triggered. Analysis code gets to pick its own gate — “resting only” is ON_SKIN | LOW_MOTION, and you apply that yourself instead of trusting that we did.
There’s a SYNTHETIC bit too. Development builds can generate a physiologically plausible backdated week on-device, so trends and baselines can be tested without wearing the watch for seven days. That data lives in the same store as real data and is always marked — on a health device, test data must never be silently mistakable for a measurement.
2.3 Epoch aggregation
Aggregating isn’t new. The shipped firmware already reduced heart rate and skin temperature to one trend point per minute, and each point already carried min, max, mean and the latest value — a 16-byte record, written into per-day files under /lfs/trhr/, /lfs/trtemp/ and so on, with a separate tier alongside it for raw ECG, BioZ, PPG and GSR captures.
What NEXT changes is where that reduction happens. The trend tier and the record tier were separate systems with separate file formats, separate retention behaviour and separate code paths to sync; the Healthy Store replaces both with one log. Getting there meant rebuilding aggregation as a stage at ingest rather than a parallel bookkeeping module — and the first cut of that store, written early in the NEXT cycle, dropped the aggregation step entirely and wrote every continuous signal to the durable log on every publish. That’s ~46,000 samples a day, and it capped retention at about 1.2 days while using under 1% of a 112 MB flash. It never shipped; we measured it, and the numbers below are what fixing it bought.
The version that ships aggregates at ingest into wall-clock-aligned epochs, picking the statistic per kind of signal rather than uniformly:
- Heart rate is spiky, so it gets mean, min and max per minute, in dedicated
HR_MIN/HR_MAXtype ids. HR can swing 70 → 150 → 70 inside a minute, and a mean-only record would quietly bury that: a 10-second spike to 150 collapses to a mean of about 110. - Skin temperature is slow, so it gets mean plus a sample count every 5 minutes. A within-epoch maximum would preferentially capture artefacts — contact pressure, sunlight, the watch coming off — rather than physiology, and the count lets a client reject a window that only a couple of samples backed.
- Steps and energy are cumulative counters, so they keep cumulative semantics — last value in the window, not deltas — and are simply rate-limited, with a force-flush whenever the counter decreases so the midnight reset can’t swallow the day’s final total.
- Spot checks — BP, SpO₂, ECG heart rate — are already sparse and skip the aggregator entirely.
The read path got fixed too: segments now hold a fixed record count, which makes locating any sequence number pure arithmetic — a seek instead of a scan.
What that came to, measured across the NEXT cycle — the “first build” column is the internal pre-aggregation store described above, not the firmware on your wrist today:
| First Healthy Store build (internal) | NEXT as shipped | |
|---|---|---|
| Samples stored per day | ~46,000 | ~5,500 |
| Daily sync payload | 830 KB | ~108 KB |
| Flash read for a full sync | 277 MB | ~108 KB |
| File opens for a full sync | ~34,000 | ~140 |
| On-device retention | 1.2 days | ~104 days |
| Summary rebuild | 8 full log scans every 5 min | 1 |
The retention figure is the one worth dwelling on, because it’s a real gain over the shipped firmware too: 128 segments × 4,480 records at ~5,500 records a day is roughly 104 days on the watch. That’s the difference between “sync regularly or lose it” and “leave it in a drawer for three months and it’s all still there.”
2.4 Continuous HRV from R-R intervals
The best find of the whole cycle wasn’t something we added. The MAX32664C wrist hub has been emitting beat-to-beat R-R intervals and per-beat confidence all along. The driver parsed them. Nothing read them. So HRV only existed if you sat down and ran a manual ECG session — we were paying the PPG’s power bill and throwing away its most valuable output.
NEXT reads them, with hard gating: beat confidence above threshold, skin contact confirmed, IMU reporting you’re still, and the interval inside a physiological 300–1500 ms. Valid intervals accumulate into five-minute windows — the standard short-term HRV window — each yielding RMSSD, SDNN, mean R-R, and coverage.
Two details are the difference between a trustworthy HRV chart and a plausible-looking wrong one:
- A rejected beat breaks the successive-difference chain rather than being bridged over. Bridging measures RMSSD across a hole in time and inflates it — in the direction a reader interprets as “great recovery.”
- Coverage is a first-class metric, so a window with three good beats in five minutes can be rejected by the client instead of charted as a real physiological change.
2.5 Stress, and what continuous HRV opens up
Having HRV continuously, rather than only when someone sits down for an ECG, changes what can be built on top of it. One thing lands in this release; one is close behind.
Stress that’s comparable to itself. The old score came from a manual 30-second EDA spot check scored on absolute skin conductance — a number that isn’t comparable between two people, or even between two sessions on the same person. NEXT scores stress from continuous HRV against your own rolling baseline, the way Whoop, Oura and Garmin do it. Until that baseline exists it returns a sentinel value, not zero: a zero there reads as “perfectly calm,” and would be a lie.
The groundwork for a morning readiness score. Continuous HRV plus sleep-gated resting heart rate are the two inputs a recovery score needs, and NEXT now collects and baselines both. There’s no readiness number on the watch or in the app yet — we’d rather ship it once it’s been validated against real overnight data than put a plausible-looking figure in front of you. It’s next on the list for this data layer.
2.6 Raw records, still yours
Aggregation is for trends. For research you need samples, so NEXT keeps a separate record tier for episodic raw captures — ECG strips, BioZ/EDA sessions, wrist and finger PPG, raw R-R interval series.
Each record is a self-describing session: a header with signal type, format, channel count, sample rate and sample count, plus a payload verified with a CRC-32 the client checks before acknowledging. Interrupted captures come back flagged PARTIAL and still usable, rather than silently truncated. Downloads are chunked and resumable.
Getting it out is a button, not a project. A downloaded record exports as CSV — a column per channel plus timestamps — or as EDF, the standard biosignal interchange format, so an ECG strip opens directly in EDFbrowser, MNE-Python, or anything else that already reads EDF, with no conversion step in between. Trend ranges export as CSV. All of it goes straight to the share sheet: no cloud, no account, no API key. The phone is the system of record for long-term history, and the phone is yours.
2.7 Robustness
Work you’ll only notice by its absence:
- A hardware watchdog plus a per-thread task watchdog. Previously a hung thread froze the watch silently and it stayed frozen. Now a missed heartbeat identifies which thread stalled and triggers a controlled reboot.
- A real fatal-error handler. A fault used to halt the system. Now it writes a crash breadcrumb to flash, reboots cold, and surfaces “recovered from a fault” on the watch a few seconds after boot.
- Bounded boot. No subsystem can brick startup by never becoming ready. The watch boots with a sensor absent.
- Interrupt-driven ECG. The MAX30001 now drives acquisition from its data-ready interrupt over the existing RTIO path instead of being polled on a timer: no missed-sample drift, fewer wakeups, cleaner timing — which HRV in particular is very sensitive to.
- UTC everywhere. The clock and every stored timestamp are UTC, and a phone-supplied offset is applied only at the display edge. Daylight saving is a one-command offset update, not an RTC rewrite that shifts your history.
3. The on-watch interface
The interface is a rewrite, not a re-skin. The whole thing is now a carousel of tiles you swipe through — Home, Heart Rate, ECG, SpO₂, Blood Pressure, Temperature, Activity, HRV and EDA — where every metric tile shares one layout language: a large accent-coloured hero number, supporting chips beneath it, and a measurement action where a measurement is possible.
Five of them, shot on the watch. Two are the rest of this article made visible: the heart-rate tile carries the minute’s minimum and maximum under the live number because that is what the store now keeps (§2.3), and the temperature tile says baseline forming rather than inventing a deviation it cannot yet compute — the same rule the app runs on (§4).
- Two watch faces — a digital face with date, battery, time and live complications, and a minimal face — plus an always-on display face, and a procedural dial motif rendering 60 radial ticks behind the home screen in your accent colour.
- Four accent colours — amber, blue, green, indigo — applied consistently across every screen from a single runtime token.
- A quick-settings shade, pulled down from any screen, with a brightness slider wired to the real display path.
- A proper settings screen — brightness, always-on, watch face, units, Bluetooth status, sleep timeout, accent, about — all persisted.
- A boot self-test screen that lists the real hardware as it comes up: nRF5340, flash, RAM, MAX30001, MAX32664C/D, MAX30208, BMI323, BLE, Zephyr, each with a genuine OK or FAIL from the actual init sequence. It’s not decoration; it’s the boot log, rendered.
- Instant screen switching. No slide animations between tiles — on a small round AMOLED, transition animation just reads as lag.
Underneath, the typography got rebuilt too: one type system on Rubik and Manrope with subsetted Material Symbols icons, replacing fifteen separate font files. Being ruthless about which glyphs actually appear on a watch face reclaimed more flash than the entire redesign consumed.
4. The app and the Dart SDK
HealthyPi Move app v3.0 ships alongside NEXT, and it’s a redesign of the same depth. The short version:
- It syncs over the Healthy Store, resumably, keyed to each individual watch — so two Moves on one phone stay properly separate.
- Raw samples land in local storage and trends are derived from them, so an improved derivation replays over the history you already have instead of serving stale rows.
- It never invents a number. “No data yet,” “still learning your baseline,” and a real reading are three distinct states on screen, and they never collapse into each other.
- Blood pressure is presented as a relative wellness trend, not a measurement — an estimated range rather than a single cuff-style number, compared against your own usual on a continuous gradient, with no clinical classification buckets.
The protocol client underneath has been pulled out into healthypi_healthy_store, a pure-Dart package with no Flutter dependency and its own test suite. Bring your own transport — BLE, serial, TCP — and you have a HealthyPi Move client in a Flutter app, a CLI tool, a desktop research script, or server-side ingest. MIT licensed, like the rest of our software.
We’ll cover the app and the SDK properly in a follow-up post: the data layer, the honest-data rules, the blood-pressure reasoning, and a walkthrough of building your own client against the SDK.
Four screens, and each one is a firmware claim arriving on the phone. Home is the whole store in one view, down to the sync line at the foot. The week of heart rate is drawn from the HR_MIN/HR_MAX epochs rather than recomputed from a mean series, which is the whole reason those type ids exist (§2.3). The HRV card is the honest-data rule in its least convenient form — the feature is new, the number is absent, and the app says so instead of estimating; the stress row on Home does the same thing in one line. And the blood-pressure screen is what “a relative wellness trend, not a measurement” actually looks like: a range, against your own usual, on a continuous scale, with the words not a cuff reading printed under the chart. The watch’s own blood-pressure tile shows the last spot estimate as a single figure; the app is where the trend lives, and it declines to dress that figure up as a measurement.
5. The new optical front end
Everything above lands on watches already in the field. This part doesn’t — it’s a board revision, so it exists only on units built from here on, which includes every backorder still pending with Crowd Supply. If you’ve been waiting, you’re getting the board that fixed the problem rather than the one that caused it.
5.1 Why it changed — and the delay it caused
Shipping stalled far longer than we said it would, and there’s no interesting spin on it.
Two things stacked up. Component availability moved under us: parts we’d designed in went long-lead or simply unavailable at the volumes we needed, and every substitution meant re-qualifying a board rather than editing a line on a BOM. And we hit yield problems in manufacturing that were concentrated almost entirely in one place — the optical sensor assembly. Neither was fixable by pushing harder; both had to be engineered out.
They have been, and we are preparing units to go out. The optical front end was also the one part of Move we were never fully happy with, and it’s the part that decides whether every downstream number — heart rate, SpO₂, HRV, stress, recovery — is worth trusting.
5.2 The problem: reflective PPG is photon-starved
You fire light into skin, a small fraction comes back modulated by blood volume, and the pulsatile (AC) part of that is frequently under 1% of the total light landing on the photodiode. Everything else is DC: static tissue, bone, ambient light, and — the one that really hurts — light that leaks sideways from your own LED into your own photodiode without ever touching tissue. Every decision below falls out of that ratio.
5.3 Integrated module → discrete stack
The old design used an integrated optical module — LEDs and photodiode in one package. It’s the conventional choice, and a reasonable one: one part to place, one part to qualify. The catch is that the geometry is fixed by the vendor, and you inherit whatever optical isolation suited their reference cover glass rather than yours.
We ran into both problems: assembly yield on one module generation, and LED-to-photodiode crosstalk across the cover glass on another. The yield one is what held up production, and no amount of process tuning fixes a package you don’t control.
NEXT drops the module for a discrete stack, so every distance on the board is ours:
| Role | Part | Notes |
|---|---|---|
| PPG AFE | MAX86141 | 3 LED drive channels, 2 independent photodiode channels, 20-bit ADC |
| Sensor hub | MAX32664C | Same wrist HR/SpO₂/HRV hub as before — unchanged |
| Green ×2 | CT DBLP31.12 (ams-OSRAM FIREFLY) | 536 nm, driven in parallel on LED1, flanking the receiver |
| Red + IR | SFH 7015 (ams-OSRAM BIOFY) | 655 nm and 950 nm on two individually addressable dies in one package |
| Photodiodes ×2 | VEMD8080 (Vishay) | 4.5 mm² each — ~9 mm² combined active area |
Two choices fall straight out of the photon budget:
- Two green emitters flanking the receiver give transmit-side spatial diversity, which is what buys you motion tolerance.
- Two large photodiodes side by side act as one big aperture. Photon count is the dominant SNR term in reflective PPG — you can’t filter your way out of not collecting enough light. Each photodiode gets its own AFE channel rather than being wired in parallel, so the hub can sum them, weight them, or throw one away when it’s the one your watchband happens to be pressing on.
That’s the whole front end on one board: the array on the skin-facing side, and on the other the AFE and hub that drive it. Five optical parts on a single axis, inside the silkscreen outline at the centre — red and IR at the top, then green, the two photodiodes, and the second green. The separations described below are the distances you can see between them.
5.4 The distances are the design
This is the part that looks like trivia and isn’t. In reflective PPG, the mean depth that light samples is roughly half the LED-to-photodiode separation, and different wavelengths need different depths:
- Green (536 nm) is strongly absorbed by haemoglobin — give it a long path and nothing survives to reach the photodiode. It wants the shallow dermal capillary bed, so green sits at 3 mm from the nearest photodiode.
- Red (655 nm) and IR (950 nm) penetrate much further and need to reach the subcutaneous arterioles where the SpO₂ ratio is cleanest. Put them too close and shallow-tissue DC saturates the ADC before the pulsatile signal gets a look in, so red+IR sits at about 6 mm.
All five components lie on one axis in roughly a 15 × 5 mm footprint. Red and IR share a single package deliberately: what matters for SpO₂ isn’t absolute path length, it’s that red and IR traverse the same tissue path so their ratio means something. Co-packaged dies guarantee that for free.
5.5 Crosstalk control
Direct LED-to-photodiode leakage — light that reaches the detector without ever passing through tissue — has to stay under roughly 0.1% of total collected light. Above that, the DC swamps the AC and neither more LED current nor cleverer filtering saves you: drive current raises signal and leakage together, so you just burn battery making the noise brighter.
The fix is mechanical, and it goes on the board in two stages:
- An opaque 3D structure is fixed to the board first. It sits over the optical row and puts one continuous wall between every emitter and every detector, with an open aperture above each component. That wall is what kills the direct path.
- Optically clear epoxy is then poured flat over the whole optical stack assembly. It fills every aperture, encapsulates the assembly, and cures to a flat, clear outer surface — so each component gets an unbroken optical path out and the whole optical assembly is sealed in, in one step.
The advantage over a separate moulded window part is that the isolation doesn’t depend on a compression fit or on how well a second component seats: the wall is bonded to the board, and the epoxy fills right up to it.
The board rules back it up: matte-black solder mask under the aperture instead of glossy, no vias or test points inside any aperture, and a hard 2 mm copper/silkscreen keep-out around every optical component. Gloss reflects. Copper reflects. In a 0.1% budget, everything reflects.
That’s the finished article, skin side up: the four gold pads are the ECG and bio-impedance electrodes, and the window between them is the optical stack under its cured epoxy. Everything described above is visible in one frame — the array on its single axis, the opaque surround holding the wall between the elements, and the flat clear surface that passes the light and seals the assembly in.
5.6 Sourcing
There’s a supply-chain dividend too, and after the last stretch we don’t treat it as secondary: discrete parts mean we’re no longer betting a production run on a single sourceable module. Every element in the table above has a second source.
6. Availability and sources
Start with the app. Update to the latest 3.0.x HealthyPi Move app first — everything else follows from there:
- Android — Google Play
- iOS — App Store
Once the app is current it handles the firmware update itself. There’s no separate DFU tool to install and no version matching to work out on your side: the app reads what’s actually on your watch and takes it to NEXT from any previously shipped firmware. Both the app and that upgrade path have been bench-validated across those versions. The mechanics of what happens during the transfer are in §1.3.
Everything is open, as always:
- Firmware — github.com/Protocentral/healthypi-move-fw (MIT; the full
HPI_HSwire contract is indocs/HPI_HS_API.md) - Hardware — github.com/Protocentral/healthypi-move-hw (CERN-OHL-P v2)
- App — github.com/Protocentral/healthypi_move_flutter (MIT)
- Dart SDK —
healthypi_healthy_storeon pub.dev (MIT; pure-Dart Healthy Store client, no Flutter dependency — bring your own transport) - Campaign — HealthyPi Move on Crowd Supply
The split is easy enough to hold in your head. If it’s software, it’s already yours: the data layer, three months of retention, continuous HRV, baseline-relative stress, the new interface, the reliability work — over the air, no charge, whichever Move you own. If it’s the optics, it’s a board change, so it ships in every unit built from here on, backorders included.
Both halves are in the repositories above. The wire contract is documented rather than something you have to reverse-engineer, the signing keys are ours to ship and yours to replace, and the flash layout is pinned where it is so a first-run watch still boots what we build today. That last one is the constraint that decides whether any of this is still useful in five years, which is why we spent the cycle working around it rather than through it.
Questions, bug reports, or something you’ve built on Move? Open an issue or find us on the ProtoCentral forum. If you’re building a client against the Healthy Store, start with docs/HPI_HS_API.md — it’s the complete wire contract, and the document our own app was built from.



