Write to BTHome devices from Home Assistant. A device declares, in its ordinary BTHome advertising, which BTHome object types it accepts writes for. Home Assistant connects briefly, writes one value — in BTHome's own format and with BTHome's own encryption — to that entry's own GATT characteristic, and disconnects. The write response is the acknowledgement: a writable value is never advertised back.
Entities, discovered on their own. There is nothing to configure beyond the bindkey prompt an encrypted BTHome device already asks for:
| What the device declares | What appears in Home Assistant |
|---|---|
| a binary object — 28 of them | a switch |
| a numeric object — 59 | a number, with that object's own unit, range and step |
the string object 0x53 |
a text |
an event object — 0x3A, 0x3B, 0x3C |
one button per value |
The device decides what is writable, not a table in this repository. The
classification is read from bthome-ble at runtime, so an object BTHome assigns
next year needs no change here. The counts are against bthome-ble 3.22.1.
A device whose values can also change by themselves makes those characteristics
readable and bumps BTHome's settings revision (0x65) when they do, so Home
Assistant knows to read them again.
- Home Assistant 2026.7 or newer, with Bluetooth working: an adapter on the host, or an ESPHome Bluetooth proxy in range of the device. If the core BTHome integration already shows your device's sensors, Bluetooth is working.
- A device that advertises a writable declaration. If you have none yet,
docs/espruino-quickstart.mdturns a Puck.js into one from a single file you paste into a web page.
Through HACS, as a custom repository:
- HACS → ⋮ → Custom repositories
- Repository
https://github.com/yerpj/bthome-writable, type Integration, Add - Find BTHome Writable in the list, Download
- Restart Home Assistant — a custom integration is not loaded until you do
Your device should then be discovered without being asked for.
docs/home-assistant-install.md is the longer
version: installing without HACS, and what to do when a control misbehaves.
Status: draft / pre-release — protocol 2.0-draft.8. It works, and every claim here is measured on three benches, two of them not mine. But the wire format is not frozen: the GATT UUIDs freeze at the first public release, and the declaration's object id
0xFFis proposed to BTHome, not yet assigned.
docs/first-use-case.md— the first working end-to-end demonstration, with diagrams, timings and what broke. Start here for what this actually does; also as a PDF.docs/measurements.md— every number this project has measured, with the command that produces it again: response time against advertising interval, write and rejection timings, advertising budgets per board, text limits, the cost of encryption on the device.docs/chain-test.md— the scenario that qualifies the whole chain with Home Assistant in it: eight checks from an advertisement reaching an entity to a burst of commands reaching the device, and what each one would catch. Its narrower companion isdocs/regression.md, the latency and delivery guard.spec/PROTOCOL.md— the protocol. Normative.spec/decisions.md— every resolved question, with the measurement or the ruling that resolved it.CHANGELOG.mdandspec/CHANGELOG.md— what changed in the two implementations, and what changed in the protocol, by draft number.spec/PLATFORMS.md— how a BTHome object becomes a Home Assistant entity, and which ones are deliberately not exposed.
Any device implementing spec/PROTOCOL.md works. One
implementation ships today: the
BTHomeWritable module
in Espruino's own module library, for nRF52-class boards — Puck.js, nice!nano
and the like. It is developed in espruino/ here, with its own unit tests, and
published from there.
The protocol was designed in the open with Gordon Williams (@gfwilliams), who created Espruino, in espruino#8024 — this project's topic — and before that in espruino#8013, kept for history. @enaon ran it on hardware that mine did not resemble and found defects no bench here would have reached, including one in the encryption path.
Two more files worth knowing about:
docs/data/github-watch.json is the state of
what this project has proposed in other people's repositories, refreshed by
python -m tools.watch_github and committed so that its git history is the
history of how the submission was received; and
spec/bthome-brief.md is the two pages the
standardisation case is made from, with
spec/bthome-dossier.md behind it.
Version 2 replaces version 1's positional bitmask, its write-all payload and its
advertising-based confirmation with a list of writable object types, one
characteristic per entry, and reads gated on the settings revision
(decisions.md, D-048). Both implementations, all three
test suites and the bench tools are on version 2, verified on a Puck.js and a
nice!nano through Home Assistant and an ESP32 proxy (D-049).
SPEC-WORKING-DOCUMENT.md is the original design rationale and is superseded by
spec/PROTOCOL.md wherever the two differ.
Three suites. The two Python ones need separate virtualenvs: the newest
bthome-ble requires a habluetooth and cryptography that the Home Assistant
release the test harness pins holds back.
# Spec tooling: test vectors, advertising fixtures, the bthome-ble checks
python -m venv .venv
.venv/bin/python -m pip install -r requirements-dev.txt # Scripts/ on Windows
.venv/bin/python -m pytest -q
# Home Assistant integration
python -m venv .venv-ha
.venv-ha/bin/python -m pip install -r ha/requirements-test.txt
cd ha && ../.venv-ha/bin/python -m pytest -q
# Espruino module
cd espruino && npm install && npm test && npm run lintTwo notes on those commands, both learned the hard way. On Windows the
interpreter is .venv/Scripts/python.exe, not .venv/bin/python. And install
only ha/requirements-test.txt: it already pins bthome-ble and
bleak-retry-connector at versions that match the Home Assistant release it
brings, and naming either package again on that line resolves it to the newest
release — which is the very clash the paragraph above warns about. CI installs
the file alone (.github/workflows/ci.yml).
test-vectors/test-vectors.json and spec/advertising-fixtures.json are
generated, and both are consumed by the Python and the JavaScript suites — they
are the contract between the two implementations. Regenerate with
python -m tools.gen_test_vectors and python -m tools.gen_advertising_fixtures;
a change to either is a change to the specification.


