What a device, a channel, a log and a reading are is the Device Model; this note is how they travel, and every reader decodes by it. A day the device was not running has no records, and a reader shows it as No Data, never 0.
A device speaks three ways:
- BTHome adverts for what is true now, which any BTHome reader sees with no code of this project’s, Home Assistant among them.
- The log, read over a connection from each reader’s own bookmark, for everything the device kept while no reader was near.
- Gadgetbridge’s Bangle.js protocol beside it, so steps reach Gadgetbridge and from it Health Connect (Reaching a Puck).
Names, units and shapes come from Health and Sensor Data Standards.
Records
A record is 8 bytes, little-endian:
| Bytes | Field | Type | Meaning |
|---|---|---|---|
| 0 to 3 | t | uint32 | Seconds since 1970-01-01 UTC; with the time-unknown flag, seconds since the device started |
| 4 | ch | uint8 | The channel, from the table below |
| 5 | flags | uint8 | Bit 0: time unknown. Bit 1: the value was clipped to fit. Bits 2 to 7: zero |
| 6 to 7 | v | int16 or uint16 | The value, read by the channel’s type and scale |
A record’s number is not stored in it. Records are numbered in the order they were written, from 0, and a record’s number comes from where it sits (below), so a reader’s bookmark is the number of the next record it wants. Numbers count within one log, and each log has an epoch, a random 32-bit number chosen when it begins, which DS.hello() reports: a log in RAM begins again at every start, and a log in Storage keeps its epoch in ds.config for as long as its segments last. A reader keeps its bookmark per device and epoch, so a device that started again is read from its new log’s first record, and nothing of the old log is overwritten or skipped. A t of 0xFFFFFFFF never occurs, so an all-0xFF slot is unwritten space.
Times are UTC, and hours are local. An hourly record covers from t to the next whole hour of the device’s local time, in the zone the latest Zone record (channel 8) gives: a whole hour normally, the rest of the hour when the device started partway through. A new zone or a set clock closes the hour where it happens, so an hourly record followed by a Zone or clock-set record ends at that record’s t. A steps record covers a minute from t, or less when the next steps record, an Activity record (channel 9), a Zone record or a set clock comes sooner, which is how an activity’s last partial minute ends where it stopped. The reader’s rule is toReadings in packages/sensors/src/format/readings.ts. So a local day’s total is exact in any zone, India’s +5:30 and Nepal’s +5:45 included, with nothing split by guesswork. The device learns its zone whenever a reader sets its clock, so after a daylight-saving change its hours follow the old offset until a reader next connects; the Zone records say which offset each hour used, so totals stay exact and only the hour’s edges sit off local time until then.
Channels
Channel numbers are never reused. Below 128 is a channel this project defines; 128 and up is an experiment, which a reader shows as raw numbers and never maps to a standard.
ch | Channel | Kind | v | Unit | When a record is written |
|---|---|---|---|---|---|
| 0 | System | event | uint16 code: 1 started, 2 clock set, 3 records lost before writing, 4 error | When it happens | |
| 1 | Steps | interval, sum | uint16, ×1 | steps | Every minute while an activity runs, 0 included, and when it stops |
| 2 | Temperature | interval, mean | int16, ×0.01 | °C | Every hour, the mean of a reading a minute |
| 3 | Battery | instant | uint16, ×1 | % | On starting, then at the first minute of each local day |
| 4 | Button | event | uint16: 1 press, 2 double press, 3 triple press, 4 long press (BTHome’s codes) | On each gesture set to drop a marker (Gestures) | |
| 5 | Moving | event | uint16: 0 still, 1 moving | On each change | |
| 6 | Opening | event | uint16: 0 closed, 1 open | On each change | |
| 7 | Light level | interval, mean | uint16, ×0.001 (0 to 1000) | none | Every hour, the mean of a reading a minute |
| 8 | Zone | event | int16, minutes east of UTC | minutes | On starting, and whenever a reader sets the clock with a different zone |
| 9 | Activity | event | uint16 kind: 0 none, 1 walk, 2 run, 3 hike, 4 cycle, 5 other | On starting, and whenever an activity starts or stops | |
| 128 to 130 | Magnetic field, x, y and z | instant | int16, raw | none | Only while an experiment turns it on |
An activity is the interval from an Activity record of a kind to the next Activity record, or to a System started record, which ends it unrecorded. A reader leaves out one shorter than 10 seconds, a bumped button rather than a session; the log keeps it, so the threshold can change without losing anything (activities and MIN_ACTIVITY_S in packages/sensors/src/format/activities.ts). An activity is named by the id of the reading that started it. Its kinds map to Health Connect’s exercise types and HealthKit’s workout types (ACTIVITY_KINDS in packages/sensors/src/format/channel-table.ts). Steps are counted only inside one, a record a minute with 0 included, so a minute with a record and 0 steps is a still minute of the activity, and outside activities there are no steps at all, never 0. Whether it was worn the device cannot tell. Temperature and light are written every hour whatever runs (Gestures, Activities).
Where records are kept
The log lives in the device’s Storage as segment files of 2048 bytes, 256 records each, named ds. and the number of the segment’s first record (ds.3072). Each segment is created at full size, blank, and records are written into its blank space, which Storage allows without rewriting the file. When the log reaches its budget, the oldest segment is erased: Storage marks it as garbage and reclaims it when it compacts, which spreads wear across the flash.
The budget is a setting: a share of the Storage free when the firmware starts, in whole segments, 20% by default and never fewer than 2 segments. A share rather than a number of days, since how many days fit depends on which channels are on and how often things happen. On a Puck with about 40 KiB free, 20% is 4 segments, 1,024 records, about 20 days at 50 records a day (temperature and light hourly, battery daily, a handful of presses), and 60 more for each hour of activity, so an hour’s walk a day brings it to about 9 days; that is arithmetic, not measured. DS.hello() reports the budget, how much of it is used, and how many days it holds at the rate records have been written since the device started (null until an hour has passed), so a reader shows days rather than bytes and can warn when an experiment fills it fast. A smaller budget wears the flash no faster: what wears it is how many records a day are written, not how many are kept.
The setting is kept in a file, ds.config, written only by firmware the owner has installed and only when a reader changes it with DS.config(). Shrinking the budget erases the oldest segments past it; growing it lets the log keep more.
New records wait in a RAM buffer of 32 and are written to Storage once an hour, or when the buffer fills, so the flash is written about 24 times a day rather than per reading. Until the device’s clock is first set, records stay in RAM, up to 256, and are rewritten with true times the moment it is set; only if that many pile up first are they written flagged as time unknown. A battery taken out loses what was in RAM.
While firmware runs from RAM (loaded for testing, not installed), the log stays in RAM entirely: writing Storage writes flash, which only firmware the owner installs may do.
The connection
A reader connects over the Nordic UART service, which on an Espruino device is its console, the same channel Gadgetbridge uses. It calls functions on one global object, DS, each line starting with \x10 so the device does not echo it, and the device answers in lines of JSON whose first key is ds, which tells them apart from Gadgetbridge’s lines (keyed t) and anything else the console prints.
| Reader sends | Device answers |
|---|---|
DS.hello() | {"ds":"hello","v":1,"id":"c1cbbe71393a","epoch":1597647644,"type":"puckjs-2.1","fw":"2v29","core":"0.2.0","t":1792512000,"zone":-240,"first":760,"next":1190,"pending":0,"activity":1,"steps":412,"log":{"pct":20,"records":1024,"used":0.42,"days":17}}. id is the device’s own Bluetooth address, which it reads itself (NRF.getAddress()); epoch names the log; t is its clock, null when unset; first and next bound what the log holds; pending counts records held back until the clock is set; activity is the kind running, 0 for none, and steps the steps counted in it so far; log is the budget (pct is 0 in firmware loaded for testing, whose log is RAM’s 256 records), the share of it used, and the days it holds. |
DS.config({logPct}) | Sets the log’s budget, as above, and answers {"ds":"config",…} with the new log. Only installed firmware keeps it past a reset. |
DS.time(t, zone) | Sets the clock to t (seconds, UTC) and the zone in minutes east of UTC, which decides where the device’s hours and days begin; a new zone closes the hour and writes a Zone record. Gadgetbridge’s E.setTimeZone sets the same zone. {"ds":"time","t":…,"zone":…} |
DS.read(from, max) | Records from number from, at most max: lines of {"ds":"log","from":N,"b64":"…"}, up to 16 records each, base64, then {"ds":"end","next":M}. If from is older than first, a {"ds":"lost","from":…,"first":…} line comes first and reading starts at first. |
DS.live(on) | While on, each record as it is written, {"ds":"now","ch":2,"v":2412,"t":…}, and each step of an activity with its count so far, {"ds":"step","n":413}. |
DS.activity(kind) | Starts an activity of kind (1 to 5), or stops the one running with 0: {"ds":"activity","kind":…}. A kind sent while another runs switches to it; Datler Sensors never sends one, and a gesture never does it. |
DS.gestures(settings) | Sets what any of press, double, triple and long do, each {"do":"activity","kind":1,"led":"g"}, {"do":"mark","led":"rb"} or {"do":"none"} (Gestures); the rest keep theirs. With no settings it changes nothing. Answers {"ds":"gestures","gestures":{…}} with all four. Installed firmware keeps them. |
DS.passkey(p) | Sets the six-digit passkey pairing needs, or none with "": {"ds":"passkey","set":…}. Installed firmware keeps it. |
A reader catches up with catchUp (packages/sensors/src/reader/sync.ts): hello, then DS.time if the device’s clock is more than 2 seconds off the reader’s or its zone differs, then DS.read from the reader’s own bookmark, keeping what came and the new bookmark in a ReadingStore. A session (openSession) queues the device’s replies for whoever waits on them, but hands its live messages, now and step, straight to their listeners (session.on), so a long walk with live on piles nothing up. virtualPuck (packages/sensors/src/reader/virtual-puck.ts) runs the firmware’s own core and log in memory behind the same transport, so a reader is tested end to end against them with no device.
Reading never deletes. Two readers keep two bookmarks, and a reading’s id is the log’s key (the device’s id and its log’s epoch, logKey in packages/sensors/src/format/readings.ts) and the record’s number, so reading a record twice makes one reading. A reply that cannot be given is {"ds":"err","e":"…"}.
Gadgetbridge’s setTime sets the same clock, so connecting either reader sets it.
Adverts
The device advertises BTHome service data (UUID 0xFCD2) every 2 seconds, encoded by bthomeAdvert in packages/sensors/src/format/bthome.ts rather than Espruino’s BTHome module, which costs more memory than a Puck can spare (Firmware Build): version 2, a packet id that changes with each update, battery (0x01) and temperature (0x02, 0.01 °C).
An encrypted advert follows bthome.io/encryption: device information byte 0x41, the objects under AES-CCM with a 16-byte key and a 4-byte tag, then the counter and the tag; the nonce is the device’s Bluetooth address, the UUID as sent, the device information byte and the counter. The counter is the device’s clock in seconds, which rises with every advert and across restarts once the clock is set, so a receiver can refuse a replayed advert without the device keeping a count; before the clock is set, nothing personal is advertised. A Puck’s firmware has AES-ECB but not CCM, so ccmEncrypt (packages/sensors/src/format/ccm.ts) builds CCM from AES blocks; bthomeEncrypted reproduces BTHome’s worked example byte for byte.
Adverts carry only battery and temperature, readings that say nothing about the person wearing the device, until a device holds a key, which waits on who may connect (below): anyone who can reach the console can read a key kept there. Steps today (as a count, since BTHome has no steps object), the button’s events (0x3A), moving (0x22) and opening (0x11) join them once they are; an unencrypted advert can be read by anyone in range.
Gadgetbridge
The device answers Gadgetbridge’s history request, {t:"actfetch", ts}, from its steps records: {t:"actfetch",state:"start"}, one {t:"act",ts,stp,act} per steps record from ts on, act naming the activity’s kind as Gadgetbridge’s ActivityKind does (WALKING, RUNNING, HIKING, CYCLING, EXERCISE), and {t:"actfetch",state:"end"}. Gadgetbridge sends the request only when its user syncs, not on connecting, with ts the time of its last successful sync, or a day back before its first; it stores each sample at the ts given, steps as given, so a minute’s steps sit at the minute’s start. A Puck answered it on 2026-10-20 with firmware that kept steps hourly, one sample for the hour from 16:03 to 17:00; the per-minute answer is tested on the host only.
Versions
This is version 1, which hello reports as v. Adding a channel needs no new version, since a reader shows an unknown channel as raw numbers; changing what a record’s bytes mean, or what a message carries, does, and a reader refuses a version it does not know.
Open
- How a key reaches a device and its readers, and where the device keeps it.
- Who may connect. The console runs any code it is sent, so anyone in range can connect to a device and change what runs on it.
- A shorter interval for temperature and light during an activity, for detail within it, at the cost of more records.
- Heart rate from a watch, matched to an activity by its interval, is the reader’s to join, not the device’s.