Releases: dasimon135/ha-bluetooth-mesh
Release list
v0.7.0-rc2 — a failed connect now says why (validation build)
Release candidate for 0.7.0, replacing v0.7.0-rc1. Install through HACS → Redownload → Show beta versions, restart Home Assistant, then run the checks below. Tag the final only if they pass.
Same six commits as rc1 plus one: the reason a failed connect gives for failing.
What changed since rc1
A failed connect now says what failed. The handler logged the exception's message, and the asyncio.timeout guarding the connect raises a TimeoutError whose str() is the empty string — so the line read mesh connect failed: and stopped there. The one message that could have explained the 2026-09-05 21:56 stall, which only a Home Assistant restart cleared, carried no reason at all. The exception type is now part of the reason.
It was also logged at debug, which was the wrong level for a failure that takes the integration unavailable. Only the miss that crosses UNREACHABLE_THRESHOLD warns — the ones below it are routine on a lamp with a single proxy slot, and probing carries on for as long as the link is down, so warning on every retry would bury the first one.
Before running these checks, consider narrowing
custom_components.bluetooth_mesh: debuginconfiguration.yaml. At the current frame-level debug output the log turns over in seconds, and the new warning — the whole point of this candidate — would be buried inbearerRX/TX lines long before you read it.custom_components.bluetooth_mesh.coordinator: debugalone keeps the useful half.
What to check, and what a failure looks like
1 — The first click after a restart is fast. With keepalive_seconds: 0, restart Home Assistant, wait for the lamp to become available, then toggle it. It should act immediately.
Failure is visible as: a multi-second pause before the lamp moves — the startup probe released the link instead of keeping it, which is the old behaviour this release removes.
2 — A timed keep-alive still releases the link. Set keepalive_seconds to something small (30), restart, toggle the lamp, then leave it alone past the timeout and confirm the vendor app can still take the lamp. Set it back to 0 afterwards.
Failure is silent, and this is the check worth the most: if the link is now held for a timed keep-alive too, nothing in any log says so — you only find out because the vendor app can no longer connect. That option exists precisely to hand the lamp's single slot back.
3 — Availability survives a Home Assistant shutdown. Restart Home Assistant and read the log during the CLOSE stage.
Failure is visible as: reconnection attempts, or Insufficient authorization, logged while the core is already shutting down — the watchdog chasing a link nobody wants back.
4 — Colour temperature and brightness are read back, not assumed. Never run against rc1, and this is what the final tag is waiting on.
Read this before running it: with keepalive_seconds: 0 the link is held permanently, and a mesh node has a single proxy slot — so the vendor app cannot see the lamp at all, and the check as written in rc1 was impossible to perform. Either use a wall remote (its changes travel the mesh and need no slot), or free the slot first:
- set
keepalive_secondsto 30 and restart Home Assistant; - leave the lamp alone past the timeout, so Home Assistant hands the slot back;
- from the vendor app, change both brightness and colour temperature, then close the app to release the slot;
- click the light in Home Assistant, or wait for the probe, so the link is re-established;
- set
keepalive_secondsback to 0.
Failure is visible as: Home Assistant showing the value it last sent rather than the lamp's own. Nothing in this release touches that path, which is exactly why confirming it did not is the cheap half of the validation.
The gap that still gates the final tag
One behaviour has never been observed live: a lost link recovering on its own, outside a Home Assistant restart — the probe on the fast interval plus the next advert triggering a connect. It is covered by tests only, and it cannot be forced on demand.
The 2026-09-05 21:56 stall is the same gap seen from the other side: the link went and nothing brought it back until Home Assistant was restarted, and the log said nothing about why. v0.7.0 should not be tagged until a link loss is seen to recover by itself. If that happens while this rc is installed, the log now names the exception type that caused it — that line is the observation the final release wants.
Everything else has been running on the author's Home Assistant since 2026-09-04 19:47 with keepalive_seconds: 0 throughout. As of 2026-09-07 the entry reports available: true, connected: true, no active repair.
v0.7.0-rc1
Release candidate for 0.7.0. Install through HACS → Redownload → Show beta versions, restart Home Assistant, then run the four checks below. Tag the final only if all four pass.
Five commits, all from one night's failures on a real network. Full detail in the changelog and in PR #21.
What to check, and what a failure looks like
1 — The first click after a restart is fast. With keepalive_seconds: 0, restart Home Assistant, wait for the lamp to become available, then toggle it. It should act immediately.
Failure is visible as: a multi-second pause before the lamp moves — the startup probe released the link instead of keeping it, which is the old behaviour this release removes.
2 — A timed keep-alive still releases the link. Set keepalive_seconds to something small (30), restart, toggle the lamp, then leave it alone past the timeout and confirm the vendor app can still take the lamp.
Failure is silent, and this is the check worth the most: if the link is now held for a timed keep-alive too, nothing in any log says so — you only find out because the vendor app can no longer connect. That option exists precisely to hand the lamp's single slot back.
3 — Availability survives a Home Assistant shutdown. Restart Home Assistant and read the log during the CLOSE stage.
Failure is visible as: reconnection attempts, or Insufficient authorization, logged while the core is already shutting down — the watchdog chasing a link nobody wants back.
4 — Colour temperature and brightness still round-trip. Change both from the vendor app or a wall remote and confirm Home Assistant follows.
Failure is visible as: Home Assistant showing the value it last sent rather than the lamp's own. Nothing here should touch that path, which is why it is worth confirming it did not.
Known gap in this candidate
One behaviour has never been observed live: recovery from a permanent link lost while availability still reads true — the probe on the fast interval plus the next advert triggering a connect. No link has dropped outside a restart on the author's network since 2026-09-04, so that path is covered by tests only. It cannot be forced on demand; if a drop happens while this rc is installed, that is the observation the final release wants.
Everything else has been running on the author's Home Assistant since 2026-09-04 19:47: two restarts recovering the proxy in about a second, one cold start with no proxy in reach recovering in 28 seconds, keepalive_seconds: 0 throughout, no errors.
v0.6.0 — colour temperature is read from the lamp, and so is its range
Added
Colour temperature is read from the lamp. It was the last attribute that only
ever reflected the last command — on/off and brightness have been read since
0.2/0.3 — so a temperature changed from the vendor app or a wall remote never
reached Home Assistant.
A lamp still ramping reports where it is and where it is going, and the answer
is where it is going. That resolution already existed for every set, and the
getters inherit it rather than deciding again.
The lamp is asked for the Kelvin range it actually tracks. The exposed range
was a pair of constants, 2700–6500, chosen as a safe default. That put a lamp's
real extremes out of reach — and it did something quieter as well: the inversion
workaround mirrors around the exposed range, so on a lamp whose real limits are
narrower, every request near the warm end was mirrored past what the lamp accepts
and clamped there.
The validation lamp for this release is exactly that case. It reports 2700–5000
K, so under 0.5.1 every request below 4200 K produced the same colour: the whole
warm half of the slider was one flat plateau. Testing the extremes could never
reveal it, because the extremes happen to map correctly either way.
The range is now read once — it is a property of the device, not a state — and
both the slider and the mirror follow it. A lamp that really is 2700–6500 puts
exactly the same bytes on the wire as before; a test asserts that rather than
leaving it to chance. A lamp that reports no valid range keeps the default.
Diagnostics reports the range each CTL node claims, as probe.nodes[].ctl_range,
null when the node gave none.
Changed
A read temperature is un-mirrored before display. A marked lamp reports the
value it was sent, so showing it raw would have put a wrong number in front of
exactly the users the per-lamp inversion option exists for.
Upgrade note
If a lamp reports a narrower range than 2700–6500, its slider maximum drops
accordingly. A scene or automation asking for a value beyond it is clamped by
Home Assistant — the rendered colour is unchanged, since the lamp was already
clamping it further along, but the stored value no longer matches what is asked.
Hardware-validated on a Häfele Connect Mesh lamp through an ESPHome Bluetooth
proxy: three test points visibly distinct where two used to be identical, and a
reload with the entity cache destroyed reads the temperature back from the lamp.
v0.6.0-rc1 — colour temperature, read from the lamp (validation build)
Pre-release for hardware validation. Not for general use.
Everything in the released v0.5.1, plus colour-temperature readback and the
lamp's real Kelvin range (PR 20).
What to check
1. What range does the lamp report?
Download diagnostics and read probe.nodes[].ctl_range. This is the interesting
one. Anything other than ["2700", "6500"] means the mirror shipped in 0.5.1 was
already off-centre on that lamp, and check 3 becomes the one that matters. A
null means the lamp reports no range and the default still stands — also a
valid outcome.
2. Does the temperature now come back from the lamp?
Change the colour temperature from the vendor app (or a wall remote), then
reload the integration. Home Assistant should show what the lamp holds, not
what it last sent. Before this build it could only ever show the latter.
3. Are the colours unchanged?
Set 2700 K and 6500 K, as in the 0.5.1 validation. On a lamp whose reported
range is 2700–6500 these must look exactly as they did. If the reported range is
different, they will shift — and the question becomes whether they are now
right, since they were being mirrored around the wrong midpoint before.
4. Does the slider's range change?
The colour-temperature slider should now offer the lamp's own limits rather than
a fixed 2700–6500.
Check 1 costs one diagnostics download and predicts the rest.
v0.5.1 — colour temperature is a per-lamp setting
Changed
Which lamps get their colour temperature mirrored is now chosen per lamp, not
per manufacturer.
Until this release the mirror was applied to every Häfele lamp, unconditionally.
Issue #7 produced the lamp that disproves that rule: a Häfele node showing warm
white when Home Assistant said cool — the workaround was itself doing the
inverting. Either that lamp is spec-conformant and the vendor list was wrong
about it, or the list was missing an entry, and no diagnostics dump tells those
two apart. Both readings agree on the same conclusion: the quirk varies within
a manufacturer, by model or by firmware, so a company identifier cannot predict
it.
Settings → Devices & Services → Bluetooth Mesh → Configure now lists every
colour-temperature lamp under Lamps with inverted colour temperature. Tick
the ones that show warm when Home Assistant says cool; leave the rest, because
mirroring a lamp that is already correct inverts warm and cool end to end. One
network can now hold both kinds, which the vendor list could not express at all.
The mirror applies when a command is sent, so a change takes effect from the
next one — ticking the box does not re-colour a lamp that is already lit.
Nothing changes on upgrade. The first setup after updating writes the old
rule into the new option: every Häfele lamp with a CTL server starts ticked. An
install that works today keeps working, and the setting is visible where it can
be unticked. A lamp imported later arrives unticked whatever its manufacturer,
which is the honest default now that the identifier is known not to predict the
quirk.
Added
Diagnostics names the mirrored lamps. state.inverted_ctl lists the
addresses whose temperature is mirrored before sending. Without it, a report of a
backwards lamp cannot say whether the inversion is the lamp's or ours — the
ambiguity that cost a round trip on #7.
Hardware-validated on a Häfele Connect Mesh lamp through an ESPHome Bluetooth
proxy: the upgrade changes nothing, unticking swaps warm and cool, and an emptied
list survives a full Home Assistant restart.
v0.5.1-rc1 — per-lamp colour temperature (validation build)
Pre-release for hardware validation. Not for general use.
Everything in the released v0.5.0, plus the per-lamp colour-temperature
inversion (#15).
What to check
Before touching anything, confirm nothing changed on upgrade: set the lamp to
2700 K (should look warm/orange) and 6500 K (should look cold/blue). The
extremes are far easier to judge by eye than a mid value.
- Settings → Devices & Services → Bluetooth Mesh → Configure — the lamp is
already ticked under Lamps with inverted colour temperature. - Download diagnostics —
state.inverted_ctlnames its address. - Untick it, save, and retry 2700 / 6500 K. On a lamp that was already
correct they should now be swapped: that is the option proving it reaches the
wire, and therefore that ticking it will fix the lamp in #7. - Restart Home Assistant and reopen Configure — it must still be unticked.
- Re-tick to return to a correct state, and confirm 2700 / 6500 K read right
again.
Step 4 is the one worth doing carefully. It is the only failure in this batch
that would be silent: if the one-time seed re-ran against an emptied list, the
box would re-tick itself on every restart with nothing in any log to say so.
v0.5.0 — the proxy connection is a device now
The GATT link finally says where it lives
This integration holds one GATT link — to whichever node advertises the network's Network ID — and that link occupies a Bluetooth connection slot on the ESPHome proxy routing it, for as long as it is held.
Nothing in Home Assistant said which BLE address that was. Every mesh device is keyed on the network UUID plus a unicast address and carries no connections at all, so anything doing slot accounting resolved the held address to no device and could only guess from how long the link had been quiet.
That guess is wrong in practice. A mesh proxy link carries traffic only when something on the mesh changes, so it is legitimately silent for hours — an overnight lighting mesh says nothing at all. On 2026-08-28 an external slot monitor reported this integration's proxy link as a stuck slot after nearly nine hours of silence. It had been healthy the whole time, and nothing it could see said otherwise.
What is new
A diagnostic sensor, Proxy address, on a device keyed on the network.
Never on a lamp: a mesh reaches many nodes through one proxy, and Home Assistant treats connections as identity — claiming one BLE connection on several devices invites the registry to merge them into one.
The entity tracks the coordinator's availability like every other entity here, and that half is the point rather than a detail. Naming the address while never reporting trouble would read as permanently healthy, trading one wrong alarm for a permanent blind spot. Going unavailable exactly when the link does gives a reader a real signal instead of a silence it has to interpret.
A rotating address cannot go stale
The address is written to the device registry with new_connections, which replaces the set rather than adding to it.
A mesh proxy advertises a random static address: stable while the node is powered, free to change when it is not. Accumulating every address ever seen would leave the device claiming BLE connections it no longer has — and a reader resolving one of those stale entries would name this device for a slot belonging to something else entirely.
Upgrading
Update through HACS and restart Home Assistant. No configuration change, no re-import of the .connect export.
One new entity appears per config entry, under a new device named after your mesh network. Existing lights, their entity IDs and their devices are untouched.
v0.4.8 — the errors speak French too
Two strings reached the user interface without passing through a translation, so a French install read them in English.
The setup failure. ConfigEntryError renders on the integration card, not just in the log, and it was built with an f-string. It now carries the corrupt_connect_export translation key.
The import rejection. Half-translated: the sentence around the reason was French while the reason injected into it was hand-written English.
A paste that is not JSON at all is now reported separately (invalid_json) from a well-formed document that is not a .connect export (invalid_connect). Their fixes differ — a truncated or word-wrapped paste versus the wrong file — and rendering them identically was the problem that message was meant to solve in the first place.
The parser's own detail stays in English on purpose: it names JSON fields that are English in the export itself.
A new test compares the key set of every shipped translation against strings.json, so a key added to one file and forgotten in another fails the suite instead of silently rendering in English for that language.
Full notes: CHANGELOG
v0.4.7 — reassembly is per node
Fixed
Two nodes sending long replies at the same time no longer cancel each other
out.
Reassembly of segmented messages was held in a single slot, shared by every node
on the network, and the identity it keyed on carried no source address. A segment
arriving from one node in the middle of another node's transfer did not
interleave — it reset. The first node's half-received message was discarded and
its acknowledgment timer cancelled with it, the node retransmitted into the same
race, and neither message was ever delivered. Nothing in any log said so.
This was there from the beginning, on the stated grounds that the stack only ever
talked to one node at a time. That stopped being true twice over: the proxy
address filter now lets status traffic from any node reach the integration, and
v0.4.6 started acknowledging segmented messages — which actively invites nodes to
send them.
Reassembly state and both timers are now kept per node. The table of tracked
nodes is bounded, evicting an idle one before one with a transfer in flight, and
a stranded transfer is dropped when its timer expires rather than left behind.
In practice this was not reachable from normal lamp control, which sends one
request at a time and whose replies fit in a single segment. It closes the gap
#9 opened rather than
fixing a symptom anyone was seeing — the transport is now correct without a
condition attached.
v0.4.6 — the transport acknowledges segmented messages
Added
The transport acknowledges segmented messages.
SegmentAck had a parser and no builder: nothing anywhere emitted one. A peer
that segments its reply and waits to be acknowledged therefore gave up, and the
exchange failed with no error anywhere — the request timed out exactly as if the
node had never received it.
A segmented message addressed to this integration's own unicast is now
acknowledged the moment it is complete, which is the ack the sender is blocked
on. An incomplete one arms the acknowledgment timer (150 ms + 50 ms per TTL hop)
and then reports the block-ack bitfield, so the peer retransmits the segment that
is actually missing rather than the whole message; a stranded transfer is
abandoned after 10 s. A transfer that has already been delivered is
re-acknowledged rather than reassembled a second time — a peer whose ack was lost
retransmits, and that must not surface as a duplicate message.
Messages sent to a group or virtual address are deliberately not acknowledged:
every subscriber would answer the sender at once.
Nothing in day-to-day use depended on this — every lighting command and Status
fits in one segment, which is why it went unnoticed. It blocked the first
device-keyed exchange that does not, which is why v0.4.4 had to abandon Config
Composition Data as a reachability probe and fall back to Config Relay.
Changed
The diagnostics probe no longer disowns its own composition field. v0.4.4
shipped a note telling the reader that a null composition proves nothing, which
was true then: the Status is segmented and nothing could ever complete it. It
completes now. On a node whose answered is true, a null composition is a
finding about that node rather than an artefact of this integration's transport,
and the note says so.
If you are debugging a node that ignores commands, this is the release to take a
fresh diagnostics dump on: the probe now carries two independent signals instead
of one, and the second discriminates.
Fixed
A rejected .connect paste now says why. The import and reconfigure forms
collapsed a truncated paste, a file that is not an export at all, and an export
whose every node is unparseable into one invalid_connect — three different
problems with three different fixes, rendered identically. The parser's own
message is carried through to the form.
A reassembly timer could outlive the connection. Stopping the controller now
closes the node, so a pending acknowledgment cannot fire against a bearer that is
already gone — which would have burned a persisted sequence number on an ack
nothing could carry.
Known limitation
Reassembly is still one transfer at a time across all peers: two nodes segmenting
concurrently destroy each other's transfer. Harmless while this integration
serialises its requests, and tracked in #11.