De fleste companion apps er tynde BLE remotes: tap en button, write en value, done. En dispenser kan ikke være så thin — en dispense er en health event, der skal recorded exactly once, enheden taler en custom binary protocol snarere end en standard GATT profile, og "don't miss a dose" kan ikke afhænge af, at the phone er awake. Her er, how we built the path from a tap til durable, reconcilable state, som en del af vores wellness & fitness app development arbejde.
At a Glance
Domain Connected wellness — smart supplement dispensing
Core Technologies React Native + Expo, react-native-ble-plx, Zustand, NestJS, MongoDB 8 (transactions)
Key Capabilities Custom BLE command/telemetry protocol, SMP/CBOR firmware updates, transactional dispense recording, timezone-aware reminders
Status Indgår Phase 2 of development
The Challenge
Vores klient var ved at skabe et Connected wellness produkt: en enhed, der dispenses supplements, controlled fra en phone. The conventional way til at build the companion app — og the way most are built — er som en thin remote, der writes a characteristic og trusts the result. Det works for a light bulb, og almost not at all for a dispenser.
Vi knew that going in, which is why we didn't build it that way. The limits af the thin-remote model er structural, not tuning problems:
- A dispense has til at be recorded exactly once. Det decrements physical stock, counts toward a daily dose, og feeds nutrient analytics. A double-count or a lost write corrupts all three.
- Enheden speaks a custom protocol, not a profile. Commands er framed binary packets; there's no off-the-shelf characteristic, der means "dispense one tablet."
- Commands og telemetry don't share a shape. Appen writes compact binary commands, men reads back JSON telemetry (battery, charging, cartridge id, water quantity) — over different characteristics.
- Firmware updates ride the same connection. Enheden has til at be upgradable in the field, over a separate management service, without a second tool.
- Reminders can't live on the device. "You haven't dosed today" er et question om time zones, history, og de-duplication — a server concern, not a phone timer.
The root cause var simple: a dispense isn't a button press, it's a transaction — both on the wire og in the database. A model, der treats it as a fire-and-forget write, can't make either half reliable.
Vores Solution
Vi split responsibilities, så each layer does what it's best at: the phone orchestrates, the device executes a custom protocol defined through vores IoT application development praksis, og the cloud er the transactional system of record — med reminders handled entirely server-side.
Architecture
- React Native + Expo + Zustand — a BluetoothStore owns the BLE connection, dispense flow, og telemetry, persisted til AsyncStorage.
- react-native-ble-plx — scanning, connect/reconnect, og characteristic read/write.
- Et custom command layer — builds framed binary commands for the device, og uses CBOR over an SMP service for firmware updates.
- NestJS API — dispense og schedule endpoints, built on the same SaaS application development foundation, vi use across client products; records each dispense inside a MongoDB transaction.
- MongoDB 8 (Mongoose) — the system of record, plus write-time roll-up collections, der downstream analytics read.
- NestJS Schedule crons + Expo push (queued via ActiveMQ/STOMP) — timezone-aware reminders, de-duplicated through Mongo logs.
- Sentry (mobile) — production error tracking i appen.

En Custom Wire Protocol
Det hard part af any BLE product er, at the device doesn't speak a standard language. Vores exposes a command/telemetry service og a separate firmware (SMP) service. Appen connects med react-native-ble-plx, then subscribes til telemetry — som arrives som JSON på the notify characteristic:
// BluetoothStore.ts — react-native-ble-plx + Zustand
const device = await bleManager.connectToDevice(deviceId, { autoConnect: true });
await device.discoverAllServicesAndCharacteristics();
// Telemetry: JSON over the notify characteristic (UUIDs are app-side constants).
bleManager.monitorCharacteristicForDevice(device.id, CH_SERVICE_UUID, TX_UUID, (_err, c) => {
if (!c?.value) return;
const t = JSON.parse(base64ToString(c.value));
// { battery_percentage, charging_status, cartridge_id, water_qty, sequence, timestamp }
set({ batteryPercentage: Number(t.battery_percentage), chargingStatus: t.charging_status });
});
A dispense er en compact framed binary command — a start marker, a command id, a length, the cartridge byte, a read/write flag, og an end marker — base64-encoded og written til the command characteristic. (De literal UUIDs og frame markers er redacted her.)
// Frame shape: [SOF] [CMD] [LEN] [DATA] [RW] [EOF] — built by the command service.
const command = SNBCommandService.buildDispenseNutritionCommand(cartridgeId); // CMD = DISPENSE
await bleManager.writeCharacteristicWithResponseForDevice(
device.id, CH_SERVICE_UUID, RX_UUID,
SNBCommandService.uint8ArrayToBase64(command),
);
Appen waits for the device's acknowledgement (den matches the command id back out af the response frame), før den treats the dispense as real — og only then records it server-side. Firmware updates use the same connection, men a different language: chunked CBOR payloads over the SMP service.
Fra Tap til Record
A confirmed dispense becomes durable state through one endpoint — POST /dispense/tablet/:cartridgeId — som writes inside a single MongoDB transaction, så the half-dozen effects either all happen or none do:
// dispense.service.ts — record exactly once, atomically
await session.withTransaction(async () => {
await this.dispensed.create([{ cartridgeId, cartridgeModalId, dispensedBy: userId, dispensedAt }], { session });
// write-time roll-up: totalDaysConsumed only increments on the first dose of the day
await this.monthly.findOneAndUpdate(
{ userId, cartridgeModalId, month, year },
{ $inc: { totalTablets: 1, ...(firstDoseToday ? { totalDaysConsumed: 1 } : {}) } },
{ upsert: true, session },
);
await this.cartridge.updateOne({ cartridgeId }, { $inc: { tablets: -1 } }, { session });
await this.dailyDose.updateOne({ userId, dateLocal }, { $inc: { tabletsTaken: 1 } }, { upsert: true, session });
await this.notifications.cancelPending(userId, 'SUPPLEMENT_REMINDER', { session }); // mark IS_MISSED
});
Den single transaction updates the dispense log, the monthly roll-up, physical stock, the daily dose count, og any pending reminder — hvilket er exactly why a dispense has til at be a transaction, not a write.
Reminders Are a Server Concern
Once a dispense er durable, the questions a phone timer couldn't answer become routine — og de're answered by cron jobs, not the app:
- Har denne user dosed today, i deres time zone?
- Er the cartridge empty (replace) or the stock empty (reorder), or both (discontinuation)?
- Har de drifted away from a health area, they used til at take?
Tre @nestjs/schedule services handle these — a schedule/dose reminder, a cartridge-state reminder, og a cross-health inactivity reminder. Each resolves the user's local time med date-fns-tz, checks Mongo for whether they've dosed today, de-duplicates against a log collection, og enqueues an Expo push via ActiveMQ. Enheden er never involved.
Results
Designing for "a dispense is a transaction" instead af a thin-remote write changed what the system can guarantee:
- Exactly-once recording. A dispense updates the log, roll-ups, stock, og daily dose atomically — no double counts, no partial writes.
- A documented device protocol, including field upgrades. Commands og telemetry er first-class, og firmware updates ship over the same connection via SMP/CBOR.
- Reminders, der er correct, not approximate. Timezone-aware, history-aware, og de-duplicated server-side — så de fire whether or not the app er open.
- Analytics, it can trust. Because every dispense rolls up at write time, the data feeding charts og adherence er always consistent med the log.
API'en, crons, og push queue run on infrastructure managed through vores cloud infrastructure services, så the same reliability guarantees hold as the device fleet og user base scale.
Teknologistak: React Native · Expo · TypeScript · react-native-ble-plx · Zustand · CBOR (SMP firmware) · NestJS · MongoDB 8 · Mongoose (transactions) · @nestjs/schedule · ActiveMQ / STOMP · Expo Server SDK · JWT · AWS S3 · AWS SES · Sentry (mobile)
Læs mere fra vores team
1. Synkronisering af Apple Health & Health Connect
2. Personaliseret opskriftssøgning: Retrieval, der knows what you should eat next
3. Optimering af Channel Logo for Different Video Resolutions

