Post-Discharge Vitals Relay Hub

This reference application is here to give you ideas and help you get started quickly. It's built on specific hardware that may not match yours, so focus on the sections that fit your use case. If you'd like to talk through your project and whether it's a good fit for Blues, reach out.
Building with an AI assistant? Point it at this accelerator's firmware in firmware/post_discharge_vitals_hub/ so it can learn how the device works and help you adapt it to your hardware.
This project is a remote patient monitoring hub for 30–60-day post-discharge recovery programs. An nRF52840 host with native Bluetooth Low Energy, paired with a Blues Notecard Cell+WiFi, scans for the patient's BLE-enabled medical devices (weight scale, blood pressure cuff, pulse oximeter, and activity band). It relays each completed reading over cellular to the Blues Notehub cloud service. When a reading exceeds a configurable clinical threshold, the hub syncs it immediately, so the care team gets the alert without waiting for the next scheduled upload. The patient plugs it in and it works, with no WiFi, no app to configure, and no network credentials to enter.
1. Project Overview
⚠️ Proof-of-concept only, not a medical device. This is a reference design for technical evaluation and developer education. It is not a cleared or certified medical device, it is not validated for diagnostic use, and it must not be used for emergency response or life-sustaining patient monitoring. Any deployment that collects, stores, or routes patient health data must address patient-data governance (including HIPAA/PHI compliance where applicable), secure data routing and storage, data retention and auditability requirements, and all applicable regulatory obligations. Consult qualified clinical, legal, and compliance teams before you collect real patient data.
The problem. Hospital discharge coordinators who manage heart failure, COPD (chronic obstructive pulmonary disease), post-surgical, and high-risk diabetic patients are sending more and more of them home with a pack of BLE-enabled monitoring devices. There's a connected scale to catch fluid retention, a BP (blood pressure) cuff to watch for hypertension spikes, a pulse oximeter to flag respiratory deterioration, and an activity band to monitor resting heart rate. The clinical evidence for remote patient monitoring in this window is strong. Daily weight checks and BP readings reduce 30-day readmissions, and early intervention on a trending SpO2 (blood oxygen saturation) drop catches pneumonia or PE (pulmonary embolism) while it's still outpatient-treatable.
The weak link is the hub. Existing RPM programs ship either a dedicated tablet the patient has to set up or a companion phone app the patient has to install, and both break constantly. The tablet needs a WiFi network that may not exist, or one with a password the patient can't find. The phone app needs a compatible phone and a patient who's comfortable installing software. In practice, a sizable share of enrolled patients never get a single reading uploaded in their first week, just because the connectivity layer fails them.
Why Notecard. Put a cellular radio in the hub and every one of those failure modes goes away. The hub doesn't need the patient's WiFi password or a companion app, and nobody in IT has to add it to the guest network. It ships pre-provisioned with a global SIM, so it's online the moment the patient plugs it into a wall outlet. That zero-touch deployment model is what cellular Notecard was built for. A device like this has to work for everyone, including an 82-year-old heart failure patient who has never configured a router. The hub is the one piece of the care program that the program can't afford to have the patient debug. Cellular removes that failure mode, and Notecard delivers the cellular connection with a SIM, an antenna, and a two-line I2C interface, so the host never deals with modem AT commands, socket management, or a session state machine.
New to Blues?
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
Deployment scenario. A small enclosure, about the size of a phone charger,
gets mailed to the patient at discharge along with the BLE device pack. The
patient plugs the USB-C cable into the included charger, sets the hub on the
nightstand, and forgets about it. The BLE devices use standard Bluetooth SIG
health profiles (Weight Scale Service, Blood Pressure Service, Pulse Oximeter
Service, Heart Rate Service). Indication-based devices (weight scale, blood
pressure cuff, pulse oximeter) disconnect on their own after sending one
measurement, and the hub goes straight back to scanning. The full connection
cycle typically takes 2–5 seconds. Heart Rate Service devices like the Polar H10
work differently. They use Notification instead of Indication and stream
samples continuously while they're worn. The firmware handles that by
disconnecting after the first HR notification and then suppressing reconnection
for 15 minutes (HR_SAMPLE_INTERVAL_MS), so a patient who wears the band for
two hours produces a bounded set of HR Notes instead of a continuous stream. The
care team sees readings in Notehub, routed to whatever RPM dashboard or EHR
integration the program uses, and the patient doesn't have to do anything after
plugging the hub in.
Device identity model. The hub is fail-closed by default. It only connects
to BLE-bonded devices (the primary gate) or to devices whose MAC address appears
in the allow-list in vitals_config.h (a secondary gate for stable-address
devices). The primary gate relies on IRK resolution in the nRF52840 SoftDevice.
When a bonded device advertises with a Resolvable Private Address, the
SoftDevice resolves it with the stored IRK and sets addr_id_peer=1 in the scan
report before the application layer sees it. Bond keys are written to flash
automatically and loaded at every boot. For initial commissioning, build with
both ALLOW_UNENROLLED_DEVICES_FOR_DEV=1 and ALLOW_COMMISSIONING_BUILD (the
two flags are required together) so the hub accepts connections from
unrecognized devices and pairing can happen. After commissioning, add any
public/static-address devices to ENROLLED_DEVICES in vitals_config.h, then
rebuild with both flags removed. The Commission step in §3 Technical Summary
walks through the complete enrollment flow.
2. System Architecture
Device-side responsibilities. The hub the patient plugged in is doing two
jobs at once. The Adafruit Feather nRF52840 Express runs Nordic's certified
SoftDevice as a BLE Central. It scans for advertisements that match a target
service UUID, connects to the first one it sees, and subscribes to the right
GATT characteristic (indication for weight, BP, and SpO2, and notification for
heart rate). Then it decodes the raw bytes. Weight comes in as a fixed-point
uint16, blood pressure and SpO2 as IEEE 11073 SFLOAT, and heart rate as a plain
integer. The host hands the decoded reading across I²C to the Notecard through
the Notecarrier F's Feather header, with note-arduino's JAdd* helpers
building the JSON request. It never has to deal with a modem, an AT command, or
a socket.
Notecard responsibilities. Once a reading lands in the Notecard's on-device
queue, the radio takes over. The Notecard opens a cellular session on the
outbound cadence configured with
hub.set (15 minutes by
default), and it sends any Note marked sync:true right away. In the other
direction, it delivers environment
variables
pushed from Notehub. That lets a care coordinator tighten or loosen the alert
thresholds for a specific patient without anyone touching the firmware.
Notehub responsibilities. Notehub ingests
and stores every event and applies the project's
routes to it. The
four reading Notefiles (weight.qo, bp.qo, spo2.qo, activity.qo) stay
separate from the alert Notefile (vitals_alert.qo) so each can go out at its
own urgency. Readings go to a long-term analytics store, and alerts go to an
on-call channel or EHR endpoint in near-real time.
Fleets group devices by patient
cohort or care program, and smart fleet
rules automatically
assign newly claimed devices to the right fleet based on serial number or
provisioning tag.
Routing to the cloud (high level). Notehub can deliver to HTTP, MQTT, AWS IoT Core, Azure IoT Hub, GCP Pub/Sub, and several other destinations. How you set up routes depends on your project, and this one doesn't include a specific downstream endpoint. The Notehub routing docs cover the options.
3. Technical Summary
Prerequisites before you start:
- A Notehub account and project (to get your ProductUID)
- The Arduino IDE with the Adafruit nRF52 board support package installed
- USB-C and Micro-USB cables for power and firmware flashing
- At least one test BLE health device (weight scale, BP cuff, pulse oximeter, or activity band) for commissioning
Here's the fastest path from parts on the bench to a first event in Notehub:
- Notehub: create a Notehub project and copy its ProductUID.
- Assemble: seat the nRF52840 Feather on the Notecarrier F, slot the Notecard into the M.2 connector, and put Mojo between a bench LiPo and the Notecarrier F's battery connector for bench validation.
- Edit one line: set
PRODUCT_UIDinfirmware/post_discharge_vitals_hub/vitals_config.h. For the initial commissioning flash, also add both-DALLOW_UNENROLLED_DEVICES_FOR_DEV=1and-DALLOW_COMMISSIONING_BUILDto your build flags. The two are required together, and neither one may appear in the shipping build. - Flash: select Adafruit Feather nRF52840 Express in the Arduino IDE and upload.
- Commission: bring each patient BLE device within range and start a
measurement. The hub connects, subscribes, and initiates BLE pairing, and the
bond keys are stored in flash. Once every device is bonded, open Notehub →
commissioning.dband look at thebond_establishedNotes. Each one shows the device'sble_addrandaddr_type. For any device whoseaddr_typeis0x00(public) or0x01(random static), add its address toENROLLED_DEVICESinvitals_config.h. Those devices advertise with a fixed address that the hub can't identify through IRK resolution alone. Devices withaddr_type0x02(random private resolvable / RPA) are recognized automatically through the stored IRK and don't need anENROLLED_DEVICESentry. The fail-closed production flow does not support devices withaddr_type0x03(random private non-resolvable). Non-resolvable addresses rotate without any IRK linkage, so the hub can't re-identify them across address changes. A patient device that reportsaddr_type0x03needs a different identity mechanism before it can be used in a production deployment. Once all the entries are added, remove both-DALLOW_UNENROLLED_DEVICES_FOR_DEV=1and-DALLOW_COMMISSIONING_BUILDfrom your build flags and reflash. The hub is now fail-closed, and it only accepts bonded and enrolled devices. - Watch: open Notehub → Events. A
_session.qoshows up within a minute, and a normal reading Note appears in its Notefile within the next 15-minute outbound window. If the reading trips a threshold,sync:trueis set on both the measurement Note andvitals_alert.qo. They typically appear in Notehub together in the same immediate cellular session (~15–60 s), or in back-to-back immediate sessions.
Here is a sample Note this device emits:
{
"weight_kg": 84.35,
"prev_kg": 82.10
}4. Hardware Requirements
| Part | Qty | Rationale |
|---|---|---|
| Notecarrier F | 1 | Feather-compatible carrier with an M.2 slot for the Notecard, an onboard LiPo connector, and Feather-header I²C routed to the Notecard. It accepts any Feather-format MCU. |
| Notecard Cell+WiFi (MBGLW) | 1 | Cellular takes the patient's WiFi out of the picture, so nobody has to provision credentials. It ships with a global prepaid SIM, with no activation and no monthly commitment per device. |
| Cellular antenna, adhesive flex, u.FL connector (included in Notecarrier F package) | 1 | Adhesive planar antenna that's good enough for home and office environments. Make sure it's in the box before assembly, because the Notecard won't register without a connected antenna. |
| u.FL to SMA bulkhead pigtail + external LTE whip antenna (optional, metal-enclosure installs only) | 0–1 | Needed if the hub goes inside a metal enclosure or in a room with poor indoor signal. Route the SMA bulkhead through a port drilled in the enclosure wall, and connect the LTE whip outside. |
| Adafruit Feather nRF52840 Express | 1 | Nordic nRF52840 SoC with native BLE 5.0, which is the radio that scans for and connects to the patient's device pack. Its Feather form factor seats directly in the Notecarrier F. The board uses a Micro-USB port for firmware flashing and serial monitoring. |
| Blues Mojo | 1 | Coulomb counter for checking cellular session energy and BLE scanning current on the bench during bring-up. The deployed hub doesn't need it. |
| USB-C wall adapter, 5V/2A | 1 | The hub runs on wall power through the Notecarrier F's USB-C port. |
| USB-C cable, ~1.8 m / 6 ft | 1 | Long enough to reach from a bedside outlet to a nightstand. Powers the hub through the Notecarrier F's USB-C port. |
| Micro-USB cable (development only) | 1 | Needed to flash firmware from the Arduino IDE and monitor serial output through the Feather's Micro-USB port. Take it out of the patient kit before shipping. |
| Project enclosure, ~100 × 65 × 30 mm | 1 | A clear polycarbonate lid lets the care team see the power LED remotely during a telehealth video call. |
BLE patient devices used for bench validation. The firmware implements the Bluetooth SIG standard service profiles and should work with any compliant device. The four models below are the specific devices each parser was validated against, and interoperability with untested devices is not guaranteed. §7.4 and §10 Limitations cover parser scope, especially for the Blood Pressure device.
| Part | Qty | Rationale |
|---|---|---|
| Withings Body (WBS06) | 1 | Bench-validation weight scale. Implements Weight Scale Service (0x181D) / Weight Measurement (0x2A9D) in SI mode. Used to validate parseWeightKg. |
| Omron M4 Intelli IT (HEM-7155T) | 1 | Bench-validation blood pressure cuff. Implements Blood Pressure Service (0x1810) with the timestamp optional field set. Used to validate parseBpMmhg. The parser walks the optional-field flags to compute the pulse-rate offset correctly for both timestamp-present and timestamp-absent layouts (see §7.4). |
| Nonin 3230 | 1 | Bench-validation pulse oximeter. Implements Pulse Oximeter Service (0x1822) / PLX Spot-Check Measurement (0x2A5E). Used to validate parseSpO2. |
| Polar H10 | 1 | Bench-validation heart-rate chest strap. Implements Heart Rate Service (0x180D) / Heart Rate Measurement (0x2A37) in 8-bit HR value format. Used to validate parseHeartRate. The Polar H10 streams notifications continuously while it's worn, so the firmware disconnects after the first sample and suppresses reconnection for HR_SAMPLE_INTERVAL_MS (15 minutes). See §7.3. |
The Blues hardware ships with an active SIM that includes 500 MB of data and 10 years of service, with no activation fees and no monthly commitment.
5. Wiring and Assembly
There's no sensor wiring, because all patient data arrives over BLE. The only electrical connections are the stacked-board I²C bus, the shared power rail, and the antenna. The table below lists each signal, and steps 1–5 walk through the assembly sequence.
Pin-by-pin connections:
| Signal | Feather nRF52840 pin | Notecarrier F header pin | Notecard / destination | Notes |
|---|---|---|---|---|
| I²C data (SDA) | SDA (labeled pad, nRF52840 P0.12) | Feather header pin 6 (SDA) | Notecard SDA | The Notecarrier F has an onboard 10 kΩ pull-up to 3.3V, so you don't need an external one. |
| I²C clock (SCL) | SCL (labeled pad, nRF52840 P0.11) | Feather header pin 7 (SCL) | Notecard SCL | Same pull-up network as SDA. |
| 3.3V logic rail (VIO) | 3V3 (Feather header pin 2) | Feather header 3.3V rail | Notecard VIO + Feather MCU logic supply | Generated by the Notecarrier F's onboard LDO. It only supplies logic/VIO. The Notecard's cellular modem and radio draw main power from the Notecarrier F's USB-C input through a separate path on the M.2 connector. |
| Ground | GND (Feather header pin 1) | Feather header GND | Notecard GND | Common ground for the entire stack. |
| Cellular antenna | N/A | N/A | Notecard MBGLW cellular u.FL port (MAIN) | Connect the adhesive pigtail u.FL plug here. Stick the flat antenna patch to the inside of the enclosure lid, or to a non-metallic enclosure wall away from the Notecard edge. |
Every connection from the Feather to the Notecard runs through the stacked Feather header, so the I²C bus and power rail don't need any jumper wires.
-
Seat the Notecard. Insert the Notecard Cell+WiFi into the M.2 Key-E slot on the Notecarrier F and secure the single M.2 retention screw. Connect the adhesive cellular antenna's u.FL plug to the cellular
MAINu.FL port on the Notecard edge (the port closest to the M.2 connector). Stick the antenna patch to a flat, non-metallic surface. The inside of the enclosure lid works well. For metal enclosures or concrete-wall installations, use the optional u.FL-to-SMA bulkhead pigtail listed in §4. Thread it through a cable-gland port in the enclosure wall and connect an external LTE whip outside. -
Seat the Feather. Press the Adafruit Feather nRF52840 Express onto the Notecarrier F's two 16-pin Feather headers. The Feather's
SDAandSCLpads (header pins 6 and 7, respectively) mate with the Notecarrier F's I²C pads, which route directly to the Notecard'sSDA/SCLpins. The 3.3V supply and GND go through the header the same way, so you don't need any jumper wires. -
Mojo (bench only). Mojo's power connections are two JST-PH battery connectors, not USB, so measure the stack on battery. Plug a bench LiPo into Mojo's
BATconnector and run Mojo'sLOADconnector to the Notecarrier F's JST-PH battery connector. Leave the USB ports unplugged while you measure, so all of the stack's current flows through Mojo. Connect the Mojo's Qwiic cable to the Notecarrier F's Qwiic port (GND / 3.3V / SDA / SCL at 3.3V logic). The Qwiic bus shares the Feather header I²C lines, and the Notecard talks to the Mojo's LTC2959 coulomb counter over that same bus. -
Power and programming ports. The Notecarrier F powers the stack through two separate paths. On the main power path, USB-C 5V feeds the Notecard's M.2 power pins directly and drives the cellular modem and radio. On the 3.3V logic rail, the Notecarrier F's onboard LDO converts that same USB supply to 3.3V and distributes it to the Feather header
3V3pin (powering the Feather MCU) and the Notecard VIO pin (I/O level reference). The Notecard's radio circuitry draws from main power, not from the 3.3V logic rail.Deployment: plug the USB-C cable from the wall adapter into the Notecarrier F's USB-C port. That one connection powers both paths and runs the entire stack.
Development and firmware flashing: both ports must be connected at the same time. Connect a Micro-USB cable to the Feather's Micro-USB port for firmware upload and serial monitoring. The Feather's UF2 bootloader enumerates over its own Micro-USB connector, and the Notecarrier F's USB-C port can't flash the Feather. Meanwhile, keep the Notecarrier F's USB-C port connected to a USB supply so the Notecard has main power. If only the Feather Micro-USB is connected, the Notecard has no main power and every Notecard API call will time out.
-
Enclosure. The Notecarrier F (about 96 × 62 mm) fits in an enclosure of 100 × 65 × 30 mm or larger. Drill one pass-through for the USB-C power cable on one side and a small vent on the opposite side, since the nRF52840 and Notecard put out modest heat during a cellular session. Home deployments with the adhesive antenna don't need an external antenna port.
6. Notehub Setup
-
Create a project. Sign up at notehub.io and create a project. Copy the ProductUID, which looks like
com.your-company.your-name:vitals-hub. -
Set the ProductUID in firmware. Open
vitals_config.hand add#define PRODUCT_UID "com.your-company.your-name:vitals-hub"at the very top of the file, above the#ifndef PRODUCT_UIDguard, with your actual ProductUID in place of the quoted string. You can also pass it as a compiler flag:-DPRODUCT_UID='"com.your-company.your-name:vitals-hub"'.PRODUCT_UIDis defined throughvitals_config.h, and the.inofile has no#define PRODUCT_UID. -
Claim the Notecard. Power the assembled hub over USB-C. The Notecard associates itself with your Notehub project automatically on its first cellular connection, so you don't have to claim it manually. The device appears in the Devices tab within 1–2 minutes.
-
Create a Fleet per patient cohort. Fleets group devices that share configuration. For an RPM program, one fleet per condition (for example,
chf-30-dayorcopd-60-day) works well. You tune threshold defaults at the fleet level, then override them per device when a specific patient's care plan calls for tighter limits. Smart Fleet rules can assign devices automatically at provisioning time, based on a tag set during kitting. -
Set environment variables. In Notehub, go to Devices → your device → Environment, or set them at the Fleet level for defaults that apply to every device in the cohort. All variables are optional, and the firmware defaults are shown below.
warning Clinical disclaimer. The default threshold values and any clinical commentary in this table are illustrative starting points for a proof-of-concept demonstration only. They are not medical advice, and they do not represent validated clinical standards. The clinical program team must select, review, and validate every alert threshold for the intended patient population and care workflow before this device is used in any patient-facing setting.
Variable Default Purpose bp_systolic_high160Systolic BP threshold (mmHg) at or above which the bp_highalert fires. This is a configurable program threshold. AHA Stage 2 hypertension begins at ≥140 mmHg systolic (≥90 diastolic), but many RPM programs set a higher initial alert point (for example, 160/100) to reduce alarm fatigue.bp_diastolic_high100Diastolic BP threshold (mmHg) at or above which the bp_highalert fires, either together with or independently of the systolic threshold.spo2_low92SpO2 threshold (%) below which the spo2_lowalert fires. Values under 92% in a recovering patient warrant clinical review.hr_high130Heart rate threshold (bpm) above which the hr_highalert fires. Applied only to Heart Rate Service (0x180D) readings from the activity band. The pulse values carried in blood pressure (bp.qo) and SpO2 (spo2.qo) Notes are recorded but not evaluated against this threshold.hr_low40Heart rate threshold (bpm) below which the hr_lowalert fires. Same scope: activity band Heart Rate Service readings only.weight_delta_kg2.3Weight gain threshold (kg, ≈ 5 lbs). Fires weight_gainwhen the current reading exceeds the previous reading in the same boot session by at least this amount. The delta resets to zero on a power cycle, because the POC doesn't persist it across reboots. This is the standard CHF fluid-retention threshold in most heart failure disease management programs.Changes take effect on the hub's next inbound sync (every 60 minutes by default). To pull updated values sooner, temporarily reduce
inboundin the Notecard'shub.setconfiguration, or trigger a sync from the blues.dev In-Browser Terminal. The Notecard fetches fresh env-var values from Notehub on each inbound session (60 minutes by default). The firmware then re-reads those locally cached values every 2 minutes (ENV_POLL_MS) withenv.get, which the Notecard answers without a cellular round-trip. That way threshold changes apply within 2 minutes of the next inbound sync, instead of sitting unused for up to another full hour. -
Configure routes. At a minimum, add one route for
vitals_alert.qo(real-time delivery to an on-call endpoint, SMS gateway, or EHR webhook) and one for each reading Notefile (to a long-term analytics store). Sendvitals_fault.qoto the same real-time endpoint asvitals_alert.qo, because it reports readings and alerts that never left the hub. With the Notefiles split, alert Notes can go to a paging system and routine readings to a patient record, and the route itself needs no filtering logic. See the Notehub routing docs.
What to look for in Notehub
In the Notehub web console, go to Devices (your newly claimed hub) and click Events to see real-time and historical Notes. You'll see these Notefiles:
_session.qo: appears within a minute of first power-on and confirms that the Notecard has cellular coverage and your ProductUID is correct. Each inbound sync (once an hour by default) and each threshold-triggered outbound sync generates a session Note.weight.qo,bp.qo,spo2.qo,activity.qo: normal readings (ones that don't trip a threshold) queue on the Notecard and show up in Notehub within the next 15-minute outbound window. A reading that does trip a threshold carriessync:trueon the measurement Note itself. That reading and its companionvitals_alert.qoNote typically arrive in the same immediate cellular session, or in back-to-back immediate sessions, instead of on the next scheduled sync.vitals_alert.qo: sent alongside the threshold-tripping measurement Note, also withsync:true, at most once per alert type every 5 minutes. Both Notes typically arrive in the same immediate cellular session (~15–60 s), or in back-to-back immediate sessions, and skip the normal 15-minute upload timer. Thebodyof each alert Note shows which threshold was crossed (for example,{"alert":"bp_high","systolic_mmhg":168,"diastolic_mmhg":98}).vitals_fault.qo: shouldn't appear on a healthy hub. It means a reading or alert Note was never confirmed: on all three attempts, the Notecard rejected it or didn't answer. A missing answer doesn't prove the Note wasn't stored, since the Notecard may have accepted it and only the I²C reply was lost, so treat each count as a possible loss. The body counts these unconfirmed Notes since the last report (for example,{"dropped_readings":1,"dropped_alerts":0}), but not which vital or what value. A nonzerodropped_alertsmeans at least one alert may not have reached the care team. §7.7 has the details.commissioning.db: written during a commissioning build (with bothALLOW_UNENROLLED_DEVICES_FOR_DEV=1andALLOW_COMMISSIONING_BUILDactive) whenever the hub successfully bonds a new device. Eachbond_establishedNote contains the device'sble_addr(colon-separated, MSB-first) andaddr_type. Check these Notes after commissioning. Devices withaddr_type0x00(public) or0x01(random static) must be added toENROLLED_DEVICESinvitals_config.hbefore the shipping build. The Commission step in §3 Technical Summary and §10 Limitations have the details.
7. Firmware Design
| File | Role |
|---|---|
post_discharge_vitals_hub.ino | Arduino entry point: global state, setup(), loop() |
vitals_config.h | Build-time configuration: PRODUCT_UID, DEBUG_VITALS, ALLOW_UNENROLLED_DEVICES_FOR_DEV, interval constants, MAC allow-list |
ble_central.h / ble_central.cpp | BLE Central: identity gate, scan/connect/disconnect callbacks, GATT data callbacks, pairing initiation, initBLE() |
notecard_helpers.h / notecard_helpers.cpp | Notecard layer: hub.set configuration, template registration, env-var fetch, Note submission with checked alert delivery |
ble_parsers.h | Static inline GATT decoders + physiological plausibility guards |
7.1 Installing and flashing
Dependencies:
- Adafruit nRF52 Arduino board support package. To install it from the
Arduino IDE Boards Manager, add the index URL
https://adafruit.github.io/arduino-board-index/package_adafruit_index.jsonunder File → Preferences → Additional Boards Manager URLs, then search for "Adafruit nRF52". Select Adafruit Feather nRF52840 Express as the board. - Blues Wireless Notecard (
note-arduino). Install it from the Arduino Library Manager (search for "Blues Wireless Notecard"), and check github.com/blues/note-arduino/releases for the latest release before flashing. - bluefruit.h, which comes with the Adafruit nRF52 board support package, so there's nothing separate to install.
Flashing: Connect a Micro-USB cable to the Feather's Micro-USB port
(not the Notecarrier F's USB-C port). Open post_discharge_vitals_hub.ino in
the Arduino IDE, select Adafruit Feather nRF52840 Express, select the
correct COM/tty port (the Feather enumerates as a serial device over its
Micro-USB connector), and click Upload. You don't need an external
programmer, because the Feather's onboard UF2 bootloader handles programming
directly over USB.
During bench bring-up, open the serial monitor at 115200 baud to watch
[BLE] and [VITALS] log lines in real time. This works in a debug build
only (DEBUG_VITALS=1 with ALLOW_DEBUG_BUILD, per the build flags below).
The default shipping build emits no serial output, so patient data is never
printed over USB in a production flash.
Build flags. Several compile-time flags in this project are guarded by a
companion acknowledgment flag. Leave out the companion and you get a hard
compile-time error, so a development or commissioning build can't accidentally
ship to a patient hub. The cleanest way to pass extra flags is with
arduino-cli:
# Debug build (verbose serial logging)
arduino-cli compile \
--fqbn adafruit:nrf52:feather52840 \
--build-property "compiler.cpp.extra_flags=-DALLOW_DEBUG_BUILD -DDEBUG_VITALS=1" \
--build-property "compiler.c.extra_flags=-DALLOW_DEBUG_BUILD -DDEBUG_VITALS=1" \
firmware/post_discharge_vitals_hub
# Commissioning build (identity bypass for initial BLE pairing)
arduino-cli compile \
--fqbn adafruit:nrf52:feather52840 \
--build-property "compiler.cpp.extra_flags=-DALLOW_UNENROLLED_DEVICES_FOR_DEV=1 -DALLOW_COMMISSIONING_BUILD" \
--build-property "compiler.c.extra_flags=-DALLOW_UNENROLLED_DEVICES_FOR_DEV=1 -DALLOW_COMMISSIONING_BUILD" \
firmware/post_discharge_vitals_hubIn the Arduino IDE, you can add extra flags by creating a platform.local.txt
file in the Adafruit nRF52 board-support-package folder with a line like
compiler.cpp.extra_flags=-DALLOW_DEBUG_BUILD -DDEBUG_VITALS=1 (see the
Arduino platform customization
docs).
The standard IDE GUI doesn't expose a build-flags field for the nRF52 board
package, and that includes the Sketch → Export Compiled Binary menu.
7.2 Module responsibilities
| Responsibility | Where |
|---|---|
| Build-time config, DBG macros, MAC allow-list | vitals_config.h |
| Notecard hub configuration and template registration | notecard_helpers.cpp: notecardConfigure, defineTemplates |
| Threshold fetch from Notehub env vars | notecard_helpers.cpp: fetchEnvVars (at boot and every 2 minutes via ENV_POLL_MS) |
| Reading Note emission + checked alert delivery | notecard_helpers.cpp: submitWeight, submitBp, submitSpO2, submitActivity |
Dropped-Note report (vitals_fault.qo) | notecard_helpers.cpp: reportDroppedNotes (every 2 minutes from loop()) |
| Device identity gate (bonding + MAC allow-list) | ble_central.cpp: isIdentifiedDevice |
| BLE scan + connect + disconnect + pairing event handling | ble_central.cpp: bleScanCallback, bleConnectCallback, bleDisconnectCallback, blePairCompleteCallback |
| GATT indication/notification callbacks + plausibility checks | ble_central.cpp: weightDataCallback, bpDataCallback, spo2DataCallback, hrDataCallback |
| BLE characteristic byte parsing + plausibility helpers | ble_parsers.h: parseSfloat, parseWeightKg, parseBpMmhg, parseSpO2, parseHeartRate, weightKgPlausible, bpPlausible, spo2Plausible, hrPlausible |
| Buffered reading dispatch and watchdog | post_discharge_vitals_hub.ino: loop() |
7.3 BLE scanning and GATT reading strategy
The nRF52840 SoftDevice acts as a BLE Central. bleScanCallback fires for every
advertisement packet the hub receives. The firmware calls
Bluefruit.Scanner.checkReportForService() against each of the four target
service UUIDs and only connects on a match. Once connected, bleConnectCallback
requests pairing (or bond re-encryption). When the link is encrypted,
bleSecuredCallback runs doDiscoverAndSubscribe(), which calls
service.discover(connHandle) to work out which of the four device types just
connected. Then it calls char.discover() followed by enableIndicate()
(for weight, BP, and SpO2, which use the GATT Indication sub-procedure) or
enableNotify() (for heart rate, which uses Notification). The SoftDevice
handles GATT ATT acknowledgment automatically. When the device transmits a
reading, the matching data callback fills in a buffered struct and sets its
valid flag, and the main loop drains that buffer on its next iteration.
The firmware uses one BLE Central connection slot at a time. If the patient
picks up the BP cuff while the scale is connected, the scale's connection
finishes first (the scale sends one indication and disconnects). The scanner
then restarts (through restartOnDisconnect(true)), and the hub picks up the BP
cuff on the next scan cycle.
Indication-based devices (weight, BP, SpO2) disconnect on their own after sending a single measurement. The full connection-measure-disconnect cycle typically finishes within 2–5 seconds.
Heart Rate Service devices use Notification instead of Indication, and they
stream readings continuously for as long as they stay connected. A device like
the Polar H10 will keep sending samples every second indefinitely. The firmware
deals with this explicitly. loop() calls Bluefruit.disconnect() after it
consumes the first HR notification, and bleScanCallback suppresses further HR
Service connection attempts for HR_SAMPLE_INTERVAL_MS (15 minutes) by checking
millis() - g_last_hr_sample_ms. That caps the activity.qo Note rate at one
sample per 15-minute window while the band is within range, no matter how long
the patient wears it.
The Bluetooth SIG assigns the four target service UUIDs:
| Device | Service UUID | Characteristic UUID | Sub-procedure |
|---|---|---|---|
| Weight scale | 0x181D | 0x2A9D | Indication |
| Blood pressure cuff | 0x1810 | 0x2A35 | Indication |
| Pulse oximeter | 0x1822 | 0x2A5E | Indication |
| Activity band | 0x180D | 0x2A37 | Notification |
7.4 Characteristic value encoding
The four measurement characteristics don't all use SFLOAT. Between them, they use three different wire encodings.
Blood pressure and SpO2 use IEEE 11073 SFLOAT. The Blood Pressure
Measurement (0x2A35) and PLX Spot-Check Measurement (0x2A5E) characteristics
encode their numeric fields as IEEE 11073 SFLOAT. That's a 16-bit type where the
upper four bits are a signed exponent and the lower twelve bits are a signed
mantissa (value = mantissa × 10^exponent). The firmware decodes these in
parseSfloat() (in ble_parsers.h). It compares the reserved sentinel values
(0x07FF = NaN, 0x0800 = NaN, 0x07FE = +Inf, 0x0802 = -Inf, 0x0801 = Reserved)
against the full 16-bit raw SFLOAT encoding before it decodes the mantissa
and exponent. Checking only the masked 12-bit mantissa field would wrongly
reject valid numbers. For example, 0x17FF has mantissa bits 0x7FF that match
the NaN sentinel mask, but its exponent field (0x1) makes it a valid number
(mantissa=2047, exponent=1, value=20470). Results are rounded to integers, since
1 mmHg / 1% granularity is clinically sufficient.
Blood Pressure parser scope. Systolic and diastolic values (bytes [1–6],
always mandatory) parse correctly for any standards-compliant BP device. The
parser decodes the optional pulse_bpm field by walking the optional-field
flags. It computes the pulse-rate SFLOAT offset as 7 bytes (mandatory header),
plus 7 more bytes if the timestamp field is present (flags bit 1). That handles
both the timestamp-included layout (the Omron M4 bench-validation device) and
the timestamp-absent layout, which covers all compliant Blood Pressure
Measurement (0x2A35) implementations. The User ID (flags bit 3) and Measurement
Status (flags bit 4) fields come after the pulse rate in the spec ordering and
aren't captured.
PLX Spot-Check Measurement (0x2A5E) parser scope. The mandatory byte layout
is [0] Flags (uint8, 1 byte), [1–2] SpO2 SFLOAT (%), and [3–4] pulse
rate SFLOAT (bpm), so a minimum valid frame is 5 bytes. The optional fields
start at byte [5] and aren't captured. They are Timestamp (7 bytes, flags bit
0), Measurement Status (2 bytes, flags bit 1), Device and Sensor Status (3
bytes, flags bit 2), and Pulse Amplitude Index (SFLOAT 2 bytes, flags bit 3).
The Flags field is 1 byte (uint8), not 2, the same width as the Flags field
in the Blood Pressure Measurement characteristic. Some GATT tutorials
incorrectly widen the PLX Flags to uint16. The bench-validation device (Nonin
3230) implements the SIG-standard layout, and parseSpO2() has been validated
against it.
Weight uses uint16 fixed-point. The Weight Measurement characteristic
(0x2A9D) encodes the weight as a plain uint16, not SFLOAT. In SI mode the
raw value has a resolution of 0.005 kg (5 g), so you divide by 200 to get
kilograms. In Imperial mode the resolution is 0.01 lb. An unsuccessful
measurement is flagged with the sentinel 0xFFFF. parseWeightKg() reads the
uint16 directly, applies the fixed-resolution multiplier, and converts lbs to
kg when the Imperial flag (bit 0 of the Flags byte) is set.
Heart rate is a plain uint8/uint16. The Heart Rate Measurement characteristic (0x2A37) carries the heart rate value as a plain unsigned integer. It's a uint8 when flags bit 0 is 0 (the common case for consumer bands) and a uint16 when bit 0 is 1. There's no SFLOAT decoding involved.
7.5 Event payload design
Each of the four reading Notefiles uses a
template
that stores Notes on the Notecard as fixed-length binary records instead of
free-form JSON, which cuts the per-reading wire size by 3–5×.
vitals_alert.qo stays untemplated, because its body changes with the alert
type.
weight.qo sample:
{
"weight_kg": 84.35,
"prev_kg": 82.10
}bp.qo sample:
{
"systolic_mmhg": 138,
"diastolic_mmhg": 86,
"pulse_bpm": 74
}spo2.qo sample:
{
"spo2_pct": 96,
"pulse_bpm": 71
}activity.qo sample:
{
"heart_rate_bpm": 68
}vitals_alert.qo sample (untemplated, immediate sync):
{
"alert": "weight_gain",
"weight_kg": 84.45,
"delta_kg": 2.35
}The alert types are weight_gain, bp_high, spo2_low, hr_high, and
hr_low. The Notecard adds its metadata envelope (timestamp and device
identity) automatically, and it appears alongside the body in Notehub. Location
is only included if the device has a configured location source. A
wall-powered indoor hub with no GNSS won't have a location field.
7.6 Sync strategy and power
Wall-powered for continuous availability. The hub runs on USB-C power (no battery), so the BLE scanner stays active 24/7. The clinical workflow depends on that. A patient may take a reading at any time (a 3 AM weight check, a midnight BP reading, and so on), and the hub has to be online to capture it immediately. There's no sleep/wake cycle and no polling interval. Measurements are event-driven and go to Notehub without delay, subject to the 15-minute outbound window, or immediately if they trip a threshold.
Notecard cellular sync. The Notecard runs in periodic mode with
outbound:15 (minutes) and inbound:60. Readings queue on the Notecard between
cellular sessions and upload in a single burst every 15 minutes. When a reading
trips a threshold, sync:true goes on both the measurement Note (for example,
bp.qo) and the companion vitals_alert.qo Note. Both skip the queue, and
they're typically delivered in the same immediate cellular session, or in
back-to-back immediate sessions. The 15-minute default is conservative. For most
RPM programs, a reading that uploads within 15 minutes of being taken is well
inside the care team's response window.
Alert cooldown. Once a vitals_alert.qo Note fires for a given alert type,
the firmware suppresses further alert Notes of that type for ALERT_COOLDOWN_MS
(5 minutes). That limits how many notifications the care team gets while a
patient stays out of range. The threshold-tripping measurement Note still
carries sync:true and arrives immediately, so the care team's data record is
complete. Only the companion alert Note, which may trigger a page or an
automated response, is held back. A reading that stays out of range across
several consecutive measurements produces at most one alert every 5 minutes
instead of one per reading cycle, which cuts down on alert fatigue. In this POC
the 5-minute floor is a firmware constant. A production deployment would expose
it as a configurable Notehub environment variable alongside the clinical
thresholds.
Radio duty cycle. The nRF52840 SoftDevice duty-cycles the BLE radio automatically, with the scanner running at a 50% duty cycle (a 100-millisecond window every 200-millisecond interval). The BLE scanner accounts for most of the continuous baseline current draw (~5–10 mA). Each cellular session accounts for the short peak-current burst (~250 mA), and even though it's brief (~15–30 seconds), it contributes a sizable share of total energy in each 15-minute sync window.
7.7 Error handling
hub.setwith response check.notecardConfigure()guards against an emptyPRODUCT_UIDbefore sending and checks the responseerrfield throughsendChecked(). A failed or rejected configuration gets logged instead of silently swallowed.- Checked delivery with retry for all Notes. Alert Notes and routine
measurement Notes both use checked enqueue through
sendVitalNoteChecked()/sendAlertNote(). Those callrequestAndResponse()and make up toALERT_ENQUEUE_RETRIES(3) attempts, so a transient I²C error doesn't drop a patient reading. Routine readings callsendVitalNoteChecked()withaddSync=false, and the normal outbound cadence applies. Threshold-tripping readings call it withaddSync=trueto trigger an immediate outbound session for that measurement, whatever the alert-cooldown state. Then, if the alert type isn't in its cooldown,sendAlertNote()submits the companionvitals_alert.qoNote, which is also checked and retried. The alert is attempted even when the measurement Note failed, so losing the measurement Note doesn't also cost the care team the alert, and the alert body still carries the value that tripped it. - Unconfirmed Notes leave a record. A Note that goes unconfirmed on all
three attempts may be lost. The firmware can't tell a rejected Note from one
the Notecard stored but whose I²C reply never arrived, so it counts both as
possible losses. (For the same reason, a retry after a lost reply can queue
the same Note twice.) Every 2 minutes (
ENV_POLL_MS),reportDroppedNotes()tries to send the counts as avitals_fault.qoNote (dropped_readings,dropped_alerts) withsync:true. The counts are kept until the Notecard accepts that Note, so a failure during a Notecard fault is still reported once the Notecard responds again. The report says how many readings and alerts went unconfirmed, not which vitals or what values. If the Notecard never recovers, nothing reaches Notehub at all, which is why a production hub needs a heartbeat (see §10). Serial logging exists only in debug builds, so on a shipping hub this Note is the only record of a possible loss. - Configuration calls checked.
hub.setandnote.templatecalls usesendChecked(), so a rejected template registration gets logged instead of silently swallowed. - Physiological plausibility. Data callbacks in
ble_central.cppapply plausibility guards (defined inble_parsers.h) before setting a reading'svalidflag. Frames outside human-survival bounds get logged and discarded. That covers impossible BP relationships, SpO2 values below 50%, HR outside 20–300 bpm, and weight outside 1–500 kg. The guards keep malformed frames and noncompliant devices from producing impossible readings or triggering spurious alerts. - GATT payload guards. BLE callbacks check the minimum
lenbefore dereferencing any byte. Measurement-unsuccessful sentinels (0xFFFF for weight, and NaN/reserved SFLOAT for blood pressure and SpO2) make the parsing helpers return −1.0f or NAN, and the callbacks discard non-positive values. - Env-var validation.
fetchEnvVars()checks for a NULL response, a non-emptyerrfield, and a non-empty string before callingatof(). Related threshold pairs (systolic/diastolic, hr_high/hr_low) are cross-checked for sensible ordering before they're applied.
7.8 Key code snippet 1: Notecard periodic sync with 15-minute outbound
The firmware sends hub.set as a checked request that guards against an empty
PRODUCT_UID and inspects the Notecard's response err field through
sendChecked(). OUTBOUND_INTERVAL_MIN (15) and INBOUND_INTERVAL_MIN (60)
come from vitals_config.h, so queued readings upload every 15 minutes and the
Notecard fetches env vars every 60:
void notecardConfigure() {
if (PRODUCT_UID[0] == '\0') {
DBG_PRINTLN("[CFG] PRODUCT_UID is empty — hub will not associate with a Notehub project");
return;
}
bool ok = false;
J *req = notecard.newRequest("hub.set");
if (req) {
JAddStringToObject(req, "product", PRODUCT_UID);
JAddStringToObject(req, "mode", "periodic");
JAddNumberToObject(req, "outbound", OUTBOUND_INTERVAL_MIN);
JAddNumberToObject(req, "inbound", INBOUND_INTERVAL_MIN);
ok = sendChecked(req);
}
// ...7.9 Key code snippet 2: immediate-sync alert on threshold trip
sync:true tells the Notecard to open a session immediately instead of waiting
for the next scheduled outbound window. A care team that needs to act on a
dangerous SpO2 within minutes can't wait for the next quarter-hour.
submitSpO2() builds the alert body, and sendAlertNote() wraps it in a
note.add to vitals_alert.qo with sync:true, making up to three checked
attempts. The alert is attempted whether or not the spo2.qo measurement Note
was accepted.
static bool sendAlertNote(J *body) {
bool ok = false;
for (uint8_t attempt = 1; attempt <= ALERT_ENQUEUE_RETRIES; ++attempt) {
J *req = notecard.newRequest("note.add");
// ...
JAddStringToObject(req, "file", "vitals_alert.qo");
JAddBoolToObject (req, "sync", true);
// ...
}
// ...
}
// ...
void submitSpO2(int16_t spo2_pct, int16_t pulse) {
// ...
if (threshold_tripped) {
// ...
sendVitalNoteChecked("spo2.qo", mbody, /*addSync=*/true);
if (alert) {
J *ab = JCreateObject();
JAddStringToObject(ab, "alert", "spo2_low");
JAddNumberToObject(ab, "spo2_pct", spo2_pct);
if (sendAlertNote(ab)) { g_last_spo2_alert_ms = millis(); }
// ...7.10 Key code snippet 3: BLE service discovery and indication subscribe
doDiscoverAndSubscribe() runs this once the link is encrypted (it's called
from bleSecuredCallback). The service discover() call walks the connected
peripheral's ATT database. On a match, setIndicateCallback() registers the
data callback, and enableIndicate() writes the CCCD (Client Characteristic
Configuration Descriptor) to turn on indications. Blood Pressure Measurement
uses Indication, so the cuff sends each measurement and the hub acknowledges it.
If either step fails, the hub disconnects to free its single Central slot.
static bool doDiscoverAndSubscribe(uint16_t connHandle) {
if (s_weightSvc.discover(connHandle)) {
// ...
} else if (s_bpSvc.discover(connHandle)) {
DBG_PRINTLN("[BLE] Blood pressure cuff — subscribing");
s_bpChar.setIndicateCallback(bpDataCallback);
if (!s_bpChar.discover() ||
!s_bpChar.enableIndicate()) {
DBG_PRINTLN("[BLE] BP: subscription failed — disconnecting");
Bluefruit.disconnect(connHandle);
return false;
}
g_oneshot_conn_handle = connHandle;
} else if (s_pulseOxSvc.discover(connHandle)) {
// ...7.11 Key code snippet 4: SFLOAT decode in ble_parsers.h
The IEEE 11073 SFLOAT type is a 16-bit packed float used for the blood pressure
and SpO2 fields. parseSfloat() sign-extends both the 4-bit exponent and the
12-bit mantissa, which is the step most BLE tutorial snippets get right.
Sentinel rejection is the less obvious part. Reserved SFLOAT values are specific
full 16-bit encodings, and they must be compared against the raw value
before any decoding. If you check only the masked 12-bit mantissa field, you'll
wrongly reject valid numbers whose lower 12 bits match a sentinel pattern but
whose exponent is nonzero. For example, 0x17FF has mantissa bits 0x7FF that
match the NaN sentinel 0x07FF, but its exponent 0x1 makes it a valid number
(mantissa=2047, exponent=1, value=20470). Weight and heart rate don't use this
decoder, and §7.4 covers their encoding formats.
static inline float parseSfloat(uint16_t raw) {
// Compare the full 16-bit raw value against each reserved sentinel before
// decoding mantissa/exponent. Checking only the masked 12-bit mantissa
// field would incorrectly reject valid encodings whose mantissa bits happen
// to match a sentinel pattern but whose exponent is nonzero (e.g. 0x17FF
// is a valid number with exponent=1, mantissa=2047, value=20470).
if (raw == 0x07FFu || raw == 0x0800u ||
raw == 0x07FEu || raw == 0x0802u ||
raw == 0x0801u) {
return NAN;
}
// Sign-extend mantissa: bits [11:0] → int16
int16_t mantissa = (int16_t)(raw & 0x0FFFu);
if (mantissa & 0x0800) {
mantissa |= (int16_t)0xF000; // propagate sign bit
}
// Exponent: sign-extend bits [15:12] to a full int8
int8_t exponent = (int8_t)((raw >> 12) & 0x0F);
if (exponent & 0x08) {
exponent |= (int8_t)0xF0; // propagate sign bit
}
return (float)mantissa * powf(10.0f, (float)exponent);
}8. Data Flow
Collected. Every time a patient uses a BLE health device: weight (kg), blood pressure (systolic/diastolic mmHg and pulse bpm), SpO2 (percent) and pulse rate, and heart rate (bpm from the activity band). The hub doesn't poll on a fixed schedule. Readings are event-driven and captured whenever the device transmits.
Transmitted.
weight.qo,bp.qo,spo2.qo,activity.qo: queued when a reading arrives and uploaded in the next 15-minute outbound sync window, unless the reading trips a threshold. In that case the measurement Note also getssync:trueand uploads immediately. These are template-encoded, so each Note is a compact binary record on the Notecard.vitals_alert.qo: emitted alongside a threshold-tripping measurement Note, also withsync:true, unless an alert of the same type went out in the last 5 minutes (ALERT_COOLDOWN_MS). The measurement Note and the alert Note typically arrive in the same immediate cellular session (~15–60 s after the reading), or in back-to-back immediate sessions, rather than on the next scheduled sync.vitals_fault.qo: sent withsync:trueonly after a reading or alert Note goes unconfirmed on every enqueue attempt. It carriesdropped_readingsanddropped_alertscounts, and the firmware retries it every 2 minutes until the Notecard accepts it.
Routed. All six Notefiles land in Notehub, and from there they go wherever
the project's routes send them. The recommended split sends the reading
Notefiles to long-term analytics or EHR staging, and vitals_alert.qo and
vitals_fault.qo to an on-call or care coordinator notification channel (SMS
gateway, webhook, EHR inbox message, and so on).
Alerts trigger on:
weight_gain: the current weight reading exceeds the previous weight reading in the same boot session by ≥weight_delta_kg. Body:alert,weight_kg,delta_kg. (The first reading of a session never alerts, and the delta state doesn't persist across power cycles. See Limitations.)bp_high: systolic ≥bp_systolic_highor diastolic ≥bp_diastolic_high. Body:alert,systolic_mmhg,diastolic_mmhg.spo2_low: SpO2 <spo2_low. Body:alert,spo2_pct.hr_high: heart rate from the activity band (Heart Rate Service, 0x180D) >hr_high. Body:alert,heart_rate_bpm. Pulse values from the BP cuff or pulse oximeter aren't evaluated against this threshold.hr_low: heart rate from the activity band (Heart Rate Service, 0x180D) <hr_lowand > 0. Body:alert,heart_rate_bpm. Same scope: activity band only.
Alert deduplication. When a reading trips a threshold, the measurement Note
always carries sync:true and uploads immediately. The companion
vitals_alert.qo Note is also rate-limited by ALERT_COOLDOWN_MS (5 minutes per
alert type). If the same alert type fired within the last 5 minutes, the alert
Note is suppressed, but the measurement Note still syncs. The care team gets a
bounded number of actionable notifications, and the raw data record stays
complete.
9. Validation and Testing
Expected steady-state. On a normal day, a post-discharge patient weighs in
once in the morning, takes BP twice (morning and evening) and SpO2 once or
twice, and wears the activity band off and on. Activity band heart rate samples
are capped at one per HR_SAMPLE_INTERVAL_MS (15 minutes), so a patient who
wears the band for two hours yields at most 8 HR Notes in that time. For a
recovering patient, the hub produces roughly 5–15 reading Notes a day across the
four Notefiles, zero alert Notes, and no vitals_fault.qo. The first set of
Notes should show up in
Notehub within 15 minutes of the first measurement after the hub comes online.
Simulating a threshold trip. The fastest way to test alert delivery is to
set bp_systolic_high to 100 and bp_diastolic_high to 60 in the Fleet
environment, so any normal BP reading trips bp_high. Set both, because the
firmware rejects the pair whenever diastolic isn't below systolic, and the
default diastolic is also 100. After the next inbound sync pulls the new
values, take a BP measurement and confirm that a bp.qo Note and a
vitals_alert.qo Note both appear in Notehub within a cellular
session-establishment window (typically 15–60 s). Set them back to 160 and
100 when you're done. Deleting the variables isn't enough, because the
firmware keeps the last accepted values until the hub restarts.
Verifying BLE connectivity. Open the Arduino serial monitor at 115200 baud
(debug build only: set DEBUG_VITALS=1 and ALLOW_DEBUG_BUILD per §7.1,
because the default shipping build emits no serial output). Each scan hit,
connection, and characteristic subscription prints a [BLE] line, and each
reading prints a [VITALS] line. If no devices show up, make sure the BLE
health device is in a measurement-ready state (most BLE health devices only
advertise right after a measurement is initiated) and that it implements the
standard Bluetooth SIG service UUID for its category.
Power validation with Mojo. Put the Mojo between a bench LiPo and the Notecarrier F's battery connector, with the USB ports unplugged (see §5, step 3). The trace has a continuous baseline with a recurring cellular burst on top of it:
| State | Expected draw | Notes |
|---|---|---|
| Continuous baseline (host + BLE scanning) | ~5–10 mA | The nRF52840 SoftDevice runs at a 50% scan duty cycle (+4 dBm). The Notecard's idle contribution (~8–18 µA) adds almost nothing to this baseline. |
| Cellular session burst (~15–30 s, once every 15 min) | ~250 mA average, with brief spikes to ~500 mA at peak LTE Cat-1 bis TX. The ~2 A figure only applies in GSM mode (2G fallback, select regions). See the MBGLW datasheet. | LTE Cat-1 bis data transfer. Also fires on any threshold-tripping measurement Note or alert Note with sync:true. |
The BLE scanner dominates the continuous baseline. The cellular burst is brief, but it contributes a sizable share of total energy per sync window, at roughly 30 s of ~250 mA (LTE Cat-1 bis) every 15 minutes. Both are well within what a standard 5V/2A USB phone charger can supply. Notecard current figures come from the MBGLW datasheet and the low-power design guide. Keep in mind that the ~8–18 µA Notecard idle figure is a module-level datasheet spec. The Mojo measures the whole powered stack at the battery input, which will read higher because of the nRF52840 and the Notecarrier F's regulator. When you bench-validate, focus on the trace shape and sync-burst cadence, and don't expect the Notecard datasheet idle figure to show up directly at the battery input.
A healthy Mojo trace for this hub shows a steady ~5–10 mA baseline (continuous
BLE scanning) with a 15–30-second burst at ~250 mA (LTE Cat-1 bis) once every 15
minutes for the scheduled cellular sync. You'll also see extra ~250 mA bursts
whenever a threshold-tripping measurement or alert Note carries sync:true.
Where the network falls back to GSM, the cellular burst can spike briefly to ~2
A during transmit. That's normal MBGLW behavior on GSM, but you shouldn't see it
in LTE Cat-1 bis–only deployments. If cellular bursts are missing or come far
less often than every 15 minutes, check that PRODUCT_UID is set and that the
Notecard has coverage (look for _session.qo events in Notehub).
Troubleshooting
Here are the issues that actually come up during first bring-up and deployment.
| Symptom | Likely cause | What to check |
|---|---|---|
| Device never appears in Notehub's Devices tab. | PRODUCT_UID is empty or wrong, or the cellular antenna isn't routed. | Make sure PRODUCT_UID exactly matches the Notehub project, and confirm the u.FL antenna pigtail is attached to the Notecard. Check for _session.qo events. If there aren't any, there's no cellular connection. |
_session.qo events arrive but no reading Notes ever appear. | No BLE health device is in range, or the device isn't advertising its standard service UUID. | Open the serial monitor at 115200 baud and watch for [BLE] Scanner started (debug build only, see the §7.1 build flags). Bring a BLE health device within a few meters and start a measurement, since most devices only advertise during an active measurement, not continuously. Confirm the device uses the standard Bluetooth SIG service UUID (not a proprietary profile). |
| BLE device connects but the reading struct is never populated. | The device sends a measurement-unsuccessful sentinel (0xFFFF for weight, or NaN or reserved SFLOAT for blood pressure or SpO2), or the characteristic byte layout differs from the SIG specification. | Check the [BLE] log lines (debug build only, see the §7.1 build flags) for the service discovery step. If discovery succeeds but the data callback never sets valid, add a Serial.printf in the callback to print the raw bytes. Compare them against the device's BLE specification, or use a BLE sniffer app (nRF Connect for Mobile works well). |
| Alerts fire on every reading even though values look normal. | A threshold set clinically too tight, stale cached env values, or a scope override shadowing the intended value. | In Notehub, check that each threshold env var holds a sensible value for the patient population. The firmware's validateEnvFloat rejects out-of-range and non-numeric values (including "0" and blank strings). Each var has explicit bounds (for example, bp_systolic_high ∈ [60, 260], hr_low ∈ [20, 100], weight_delta_kg ∈ [0.5, 20]), and a rejected value falls back to the previously accepted threshold instead of being applied. Notehub applies env vars hierarchically. Project scope is the baseline, fleet scope overrides it, and device scope overrides both. A device-level value silently shadows a fleet or project default, so check all three scopes when a threshold behaves unexpectedly. |
A vitals_fault.qo Note appears. | A reading or alert Note was rejected, or got no answer from the Notecard, on all three attempts. A Note that got no answer may still have been stored. Common causes are a Notecard that isn't fully seated, an I²C fault, or a Notecard without main power (§5, step 4). | A nonzero dropped_alerts means at least one alert may not have reached the care team, and the report doesn't say which vital or value, so handle it under your program's missed-alert procedure. Then reseat the Notecard and check the hub's USB-C power. A debug build (§7.1) logs each failed attempt, with the Notecard's err text when there is one. |
| Weight readings are off by a factor of ~2.2. | The device is transmitting in Imperial (lbs), but the flags byte isn't being parsed correctly. | Bit 0 of the Flags byte (byte 0) sets SI vs. Imperial. Log data[0] in weightDataCallback and check it. If the device always sends Imperial, the parseWeightKg helper already converts lbs → kg when the flag is set. |
| No cellular sync for more than 15 minutes. | The Notecard is in a coverage gap, or the firmware is stuck before calling submitWeight / submitBp / etc. | From the blues.dev In-Browser Terminal, run hub.status for the Notehub connection state and card.wireless for signal strength. If coverage is good but syncs are missing, confirm the main loop() is running (add a serial heartbeat print every 30 seconds). |
| Mojo bench trace shows no cellular bursts at all. | hub.set wasn't applied, or the Notecard is in minimum mode from a prior configuration. | Send {"req":"hub.set","mode":"periodic","outbound":15,"inbound":60} from the blues.dev In-Browser Terminal to reset the sync mode. Then power-cycle the hub. The sketch calls notecardConfigure() in setup(), which re-applies the configuration. |
If a problem isn't on this list, the Blues community forum is the fastest place to get a second pair of eyes on a Notecard + BLE setup.
10. Limitations and Next Steps
Reminder: this is a proof-of-concept reference design, not a cleared medical device. It is not intended for diagnostic, emergency, or life-sustaining use. Any production deployment must address PHI/HIPAA compliance, secure data routing and storage, auditability, data retention policies, and applicable regulatory review before handling real patient data.
The hub is scoped to the post-discharge window on purpose. It serves one patient from one wall outlet, speaks the standard Bluetooth SIG health profiles, and uses the smallest commissioning workflow that works. Per-patient baselines, multi-device concurrency, OTA firmware, and proprietary activity-band data are all real product features, but they belong in a follow-on design instead of complicating this reference design.
Simplified for the POC
Every simplification below was a deliberate scope choice. Once a real post-discharge program is running the hub, a production deployment will close each one with added concurrency, a configurable knob, or a hardened security path. As the warning above says, this is a proof-of-concept reference design, not a cleared medical device.
One BLE connection at a time. The nRF52840's Central role supports multiple concurrent connections, but the sketch only opens one. If a patient manages to trigger two devices at once (unlikely with health devices that connect, measure, and disconnect quickly), the second connection attempt waits until the first device disconnects. Adding multi-connection support is straightforward with the Adafruit nRF52 library.
Alert cooldown is a fixed firmware constant, not remotely configurable. The
hub suppresses repeated vitals_alert.qo Notes of the same type for 5 minutes
(ALERT_COOLDOWN_MS), and you can't adjust that duration per patient without
reflashing the firmware. A production deployment would expose it as a Notehub
environment variable alongside the clinical thresholds, so care coordinators
could tighten or relax the alert rate for individual patients.
Weight-delta alerting, not absolute. The weight_gain alert fires on the
change from the previous reading, not on absolute weight. On first boot there's
no previous reading, so a delta alert isn't possible. A daily-comparison window
(today vs. the same time yesterday) is a more dependable clinical metric. It
requires persisting the previous day's reading, which you could do by storing it
in a local-only .dbx database Notefile (for example, weight_state.dbx),
which survives restarts of both the host and the Notecard.
Heart rate only from activity band. The Heart Rate Service (0x180D) provides heart rate, but step count isn't part of the standard service. It lives in proprietary, manufacturer-specific GATT services that differ from one activity band brand to the next. The firmware only captures heart rate from that service.
Blood Pressure Measurement fields user_id and measurement_status aren't
captured. parseBpMmhg correctly decodes systolic, diastolic, and pulse rate
for any compliant 0x2A35 layout. Only the timestamp field (flags bit 1) affects
the pulse-rate offset. When the timestamp is present, the parser adds 7 bytes to
the mandatory 7-byte header, so it handles both timestamp-present (Omron M4) and
timestamp-absent layouts correctly. The User ID (flags bit 3) and Measurement
Status (flags bit 4) fields come after pulse rate in the spec ordering. The
firmware doesn't use them and discards their values.
BLE bonding requires a commissioning step, and public/static-address devices
also require an allow-list entry. The hub is fail-closed by default:
unrecognized devices are ignored. Before the hub ships to a patient, you need a
commissioning build with both -DALLOW_UNENROLLED_DEVICES_FOR_DEV=1 and
-DALLOW_COMMISSIONING_BUILD to pair that patient's specific devices and store
their bond keys. After commissioning, check the bond_established Notes in
commissioning.db on Notehub. Any device whose addr_type is 0x00 (public)
or 0x01 (random static) must also be added to ENROLLED_DEVICES in
vitals_config.h. Bonding alone isn't enough for those devices, because the hub
can't identify them through IRK resolution. Remove both commissioning flags
before deploying to patients. The MAC allow-list is a compile-time constant, so
updating it takes a reflash.
BLE pairing uses encrypted but unauthenticated bonding. The commissioning
step uses Just Works pairing (setMITM(false)) because most consumer
health devices lack input/output capabilities. That gives you an encrypted BLE
connection, but it offers no man-in-the-middle protection during the initial
key exchange. For production deployments that handle patient data, the
clinical and security teams should define a controlled commissioning procedure
(for example, pairing only in a secured kitting environment at the point of
device preparation) and evaluate device and vendor choices that support
authenticated pairing modes (Numeric Comparison or Passkey Entry) where
input/output capabilities exist. They should also review the full threat model
for the deployment setting before processing real patient data.
No offline-reading catchup. If the hub is unplugged for several days and then plugged back in, it has no way of knowing which readings it missed. Readings taken while the hub was offline are simply absent from the record.
Activity band steps not captured. The standard Heart Rate Service (0x180D) doesn't carry step count. Activity bands that expose a proprietary step-count characteristic need a device-specific firmware extension.
Production Next Steps
Before this proof-of-concept is ready for a real patient population, firmware delivery, baseline modeling, and supply-rail visibility all need hardening.
Investigate host firmware OTA. Notecard Outboard Firmware Update targets hosts whose bootloader the Notecard can drive over UART (for example, the STM32 System Memory bootloader). The Adafruit Feather nRF52840's UF2 bootloader uses USB mass-storage enumeration, which doesn't fit that model. Before OTA firmware updates can be a supported feature, a production deployment would need either a custom UART-accessible bootloader on the nRF52840 or another delivery path (for example, Nordic Bluetooth DFU triggered by an environment-variable flag delivered through the Notecard).
Per-patient baseline learning. Store a 7-day rolling average weight in a
local-only .dbx Notefile, and alert when a single day's reading is more than
weight_delta_kg above that rolling average instead of just above the previous
reading.
Heartbeat Note. Send a heartbeat.qo every 24 hours even when no readings
arrived, so the care team can tell "patient not using devices" apart from "hub
offline."
Low-voltage notification. Notecard's card.voltage response monitors the
supply rail, so a voltage_low alert can page the care coordinator if the hub
is losing power or the USB supply is marginal.
Remotely configurable alert cooldown. Expose ALERT_COOLDOWN_MS as a
Notehub environment variable, so care coordinators can tune the alert rate per
patient without a reflash.
11. Summary
The 82-year-old heart failure patient sets the hub on the nightstand and forgets it's there. The discharge coordinator who mailed it sees weight, blood pressure, SpO2, and heart rate show up in Notehub the first time a device is used, without a password, an app, or a support call. If any reading crosses a clinical threshold, the coordinator gets paged within minutes. A Feather nRF52840 does the work, talking to the BLE health-device profiles on one side and a cellular Notecard on the other. Care coordinators retune per-patient thresholds from Notehub environment variables, with no firmware change and no truck roll. Some patients don't have home WiFi, can't configure a tablet, or are recovering at a family member's house. For them, this is the only hub architecture that reliably delivers the clinical outcome the program was built to produce.
On This Page
- 1. Project Overview
- 2. System Architecture
- 3. Technical Summary
- 4. Hardware Requirements
- 5. Wiring and Assembly
- 6. Notehub Setup
- 7. Firmware Design
- 7.1 Installing and flashing
- 7.2 Module responsibilities
- 7.3 BLE scanning and GATT reading strategy
- 7.4 Characteristic value encoding
- 7.5 Event payload design
- 7.6 Sync strategy and power
- 7.7 Error handling
- 7.8 Key code snippet 1: Notecard periodic sync with 15-minute outbound
- 7.9 Key code snippet 2: immediate-sync alert on threshold trip
- 7.10 Key code snippet 3: BLE service discovery and indication subscribe
- 7.11 Key code snippet 4: SFLOAT decode in ble_parsers.h
- 8. Data Flow
- 9. Validation and Testing
- 10. Limitations and Next Steps
- 11. Summary