BTHome Writable
BTHome is how an Espruino device tells Home Assistant what it has measured. This module adds the other direction: Home Assistant writes a value to the device — a light, a setpoint, a line of text on a display — in BTHome's own object format.
The device lists, in its ordinary BTHome advertising, the object types it accepts writes for. A receiver connects briefly, writes one BTHome object to that entry's own GATT characteristic, and disconnects. The write response is the acknowledgement, and a writable value is never advertised.
advertising 40 00 09 01 61 FF 01 1E battery, and "entry 1 accepts 0x1E"
write 1E 01 -> 2FAA0001 one object, one characteristic
Status
This is a proposal, not yet part of BTHome. Worth knowing before you build something on it:
- the declaration uses object ID
0xFF, which BTHome has not assigned. If they assign a different one, this module changes a constant and your receiver must change with it; - the GATT UUIDs are provisional until the first release;
- receiving it needs the bthome-writable custom integration for Home Assistant, installable through HACS. Core BTHome is unaffected: it skips the declaration and goes on reading your sensors exactly as before, so adding this to a device breaks nothing that already works.
Everything else is BTHome's own — the object table, the encodings, and the AES-CCM encryption. The design was worked out in the open in this discussion.
Usage
var bw = require("BTHomeWritable");
var lamp = false;
bw.setup({
advertise : [
{ type:"battery", get:()=>E.getBattery(), interval:300000 }, // read it every 5 min
{ type:"light", set:v=>{ lamp = v; digitalWrite(LED1, v); } }
],
interval : 1000,
onError : e => print("rejected:", e.code, e.message)
});
print("writable entries:", bw.plan().entryIds); // [ 30 ], i.e. 0x1E
An entry with get only is an ordinary sensor, advertised as usual. An entry
with set is writable: it is listed in the declaration, gets characteristic
2FAA0001, 2FAA0002… in entry order, and its value is never advertised.
Give a writable entry a get as well if its value can change without a
write — a knob, a physical button, a schedule. Its characteristic then
becomes readable, the device advertises BTHome's settings revision (0x65),
and you call bw.changed() whenever such a change happens so that receivers
know to read it again.
setWatch(function() {
lamp = !lamp;
digitalWrite(LED1, lamp);
bw.changed(); // tell receivers the value moved by itself
}, BTN, { repeat:true, edge:"rising", debounce:50 });
Types BTHome cannot encode
Button events and raw objects are declared by ID instead of by name:
{ id:0x3A, length:1, set:v=>print("event", v) } // fixed length
{ id:0x54, variable:true, set:b=>print(b) } // length-prefixed
{ id:0x3B, set:op=>print("command", op) } // command: framing is fixed
Their set receives the value bytes, the event code, or a command's opcode.
Encryption
Give setup() a bindkey and the device advertises, accepts writes and serves
reads encrypted, with BTHome v2's AES-CCM in both directions:
bw.setup({
advertise : [ { type:"light", set:v=>digitalWrite(LED1,v) } ],
interval : 1000,
bindkey : "231d39c1d7cc1ab1aee224cd096db932", // 32 hex characters
counterReport : true
});
This needs the AESCCM module, which the Web IDE will fetch for you.
It uses your firmware's own AES.ccmEncrypt where there is one — Bangle.js 2
builds it, Puck.js does not — and builds CCM out of AES.encrypt otherwise, so
you do not have to care which board you are on.
Encryption costs 8 bytes of service data for the counter and the MIC, which is a third of the packet. Plan for it.
counterReport : true is worth turning on. Without it the device resumes its
write counter ahead of the receiver after every restart, and silently refuses
commands until the receiver catches up; with it the receiver asks the device
where it is instead of guessing.
How much fits in one packet
The BTHome service data has about 24 bytes to spend, and 16 once encrypted. Against that:
| packet id | 2 bytes, always |
| settings revision | 2 bytes, if any writable entry is readable |
| the declaration | 2 bytes plus one per writable entry |
| each advertised sensor | its own object, 2 bytes and up |
Writable values cost nothing on the air beyond their one byte in the declaration — they are read over GATT, not advertised. So five settable parameters cost 7 bytes in total, and what overflows a packet is usually the sensors.
If the radio refuses a packet, try showName:false first: the name shares the
same 31 bytes, and on some boards it takes most of them.
Reference
setup(options)
| Option | |
|---|---|
advertise |
the entry list, in order. Entry k is characteristic 2FAA000k |
interval |
advertising interval in milliseconds, 20–10000 (default 2000). A per-entry interval, also in milliseconds, is how long that sensor's value may be reused before get() is called again; 0 reads on every packet |
fastInterval |
advertising interval in milliseconds while a receiver is around, floored at 100 (the default) |
fastTimeout |
milliseconds to keep advertising fast after a disconnect (default 30000) |
whenConnected |
keep advertising during a connection (default true) |
maxWriteLength |
largest accepted write, in bytes (default 128) |
bindkey |
16-byte AES key, as 32 hex characters or an array |
showName |
put the device name in the advertising packet (default true) |
maxServiceData |
what your radio will really accept, in bytes. Set it and the packet is checked at setup() rather than at the first transmission |
counterReport |
offer the counter report at 2FAA1000 (default false, and only with a bindkey) |
onError |
called with a rejected write's error, which is the only place one shows |
Methods
bw.update() |
re-read every sensor and rebuild the packet now |
bw.changed() |
a writable value changed by itself: bump the settings revision so receivers re-read it |
bw.setAdvertisingInterval(ms) |
change the advertising interval, in milliseconds, without re-running setup() |
bw.setFastTimeout(ms) |
change how long the fast window lasts, in milliseconds |
bw.plan() |
what setup() worked out: entryIds, the writable entries and their UUIDs |
Notes
The packet is rebuilt when something changes, not on every interval. The
module re-reads an advertised entry when that entry's own interval has elapsed,
and builds a new packet only if an encoded value has moved -- in which case the
packet id moves with it. That is what BTHome asks for: the packet id "should
be increased on every change in data", a receiver "should only process the
advertisement if the packet id is different compared to the previous one", and
"this allows you to send multiple advertisements that are exactly the same".
It is also much cheaper. A rebuild is 26-38 ms of the one thread on an nRF52,
and on a device with a bindkey it is re-sealed with AES-CCM: rebuilding an
identical packet every second measured at over 100 uA of idle current for
nothing. bw.update() forces a rebuild, and bw.changed() is how a value that
moved on its own announces itself.
It wakes when something falls due, and not otherwise. There is no fixed
timer. Each wake re-reads whatever is due, publishes if anything moved, and
arms the next wake for the soonest entry still to come, plus 100 ms so that it
lands past the deadline rather than just short of it -- which is less rounding
than the fixed timer it replaces, where a 30 s entry inspected every second
was re-read every 31. A device whose entries are all slow wakes slowly --
interval: 1000 with a battery at interval: 300000 wakes once every five
minutes, not three hundred times -- and a device that advertises nothing it has
to read holds no timer at all. What this is worth on a current meter is honest
to state: next to nothing. An empty wake measured under 10 us on an nRF52, and
a module whose packet is already left alone idles within about 5 uA of one that
is not loaded. It is the right shape, not a saving.
Changing how fast it advertises does not build a packet either. A receiver
connecting, the quiet window after it expiring and bw.setAdvertisingInterval()
all change one field of the advertising options, so the module hands the radio
back the bytes already on air rather than building the same ones again: the
packet id stays where it is, and on a device with a bindkey so does the
advertising counter -- which is exactly the repetition the format allows. On a
nice!nano that takes one connect-and-disconnect cycle from two rebuilt packets
to two repeated ones, 189 ms of the one thread down to 7 with a bindkey.
A rejected write still looks delivered. Espruino acknowledges a write
before your handler runs, so a device that refuses one cannot say so over the
air. onError is where it shows, and it is worth printing during development.
One central at a time. These boards serve a single connection, so a receiver's write will fail while the Web IDE is connected — including, quietly, while you are watching the console wondering why nothing happens.
Keep the sketch in RAM while you are experimenting. An advertising payload
the radio refuses throws inside setup(), the radio stops to reconfigure and
stays stopped, and a device that does not advertise cannot be connected to, so
it cannot be fixed over the air. A power cycle undoes a sketch in RAM; one
saved to flash repeats the problem at every boot.
References
- bthome-writable — the protocol
(
spec/PROTOCOL.md), the Home Assistant integration, and a decision log giving the measurement behind each choice - Ten-minute replication with a Puck.js
- The design discussion
- BTHome — the uplink module this builds on
- AESCCM — needed only when you set a
bindkey
This page is auto-generated from GitHub. If you see any mistakes or have suggestions, please let us know.