Skip to content

Fix device merge, position wipe, staleness, home_id gate; add gateway-driven home key extraction#34

Open
Cloudore wants to merge 16 commits into
patman15:mainfrom
Cloudore:main
Open

Fix device merge, position wipe, staleness, home_id gate; add gateway-driven home key extraction#34
Cloudore wants to merge 16 commits into
patman15:mainfrom
Cloudore:main

Conversation

@Cloudore

Copy link
Copy Markdown

Summary

Four bug fixes around the BLE coordinator and a new config-flow path that pulls the AES home key out of a PowerView Gen3 gateway over HTTP and persists it in .storage/, so users no longer have to hand-edit const.py (and have it wiped on every HACS update).

All on top of 37a4907. Verified against a live 10-shade installation with a mix of encrypted KDT curtains and unencrypted ACR rollers.

The bugs and the fixes

1. Device registry merge from a non-unique identifier (coordinator.py)

device_info was returning (DOMAIN, self.name) as one of the registry identifiers. self.name is whatever the BLE adapter currently reports as the local name — which, on at least one batch of shades in the wild, transiently switches to the string "AWEI T13 Pro" (a generic earbud firmware fallback, presumably picked up via a stack collision or factory misconfiguration). When two shades pass that string through device_info on the same coordinator pass, HA's device registry merges them into a single device record because they share an identifier. The user's name_by_user, area_id, and HomeKit exposure all go with it.

I hit this in my own install with four KDT curtains collapsed into one "AWEI T13 Pro" device.

Fix: identifier is now (DOMAIN, format_mac(self.address)). MACs are immutable; local names are not.

If anyone else is already living with merged records, the patch alone won't un-merge them — HA's registry doesn't reverse the operation automatically. A one-off .storage/core.device_registry edit (split the merged record into N records, reassign entities by the MAC encoded in their unique_id) was sufficient in my case. Happy to ship that as a separate utility if you'd like.

2. Position is wiped on every BLE event (coordinator.py)

self.data = {ATTR_RSSI: service_info.rssi}
if change == bluetooth.BluetoothChange.ADVERTISEMENT:
    self.data.update(self.api.dec_manufacturer_data(...))

On any event where dec_manufacturer_data returns [] (advert without a V2 payload — happens during connect, scan responses, some BLE proxies), self.data ends up with only rssi. Then:

  • current_cover_position resolves to None
  • is_closed returns None == 0False
  • is_opening/is_closing fall through their guards
  • CoverEntity.state settles on STATE_OPEN

So a closed shade flickers to state="open", current_position=None every time a non-V2 packet arrives. The cover entity then writes that to state history, and HomeKit / Lovelace / dashboards show the wrong thing.

Fix: preserve the last-known V2 fields across events. Only refresh ATTR_RSSI unconditionally; only update positional fields when a V2 frame actually decoded. State-of-the-moment flags (is_opening, is_closing, battery_charging, etc.) are still cleared per-event so they don't latch after movement ends.

3. Stale entities masquerade as fresh (coordinator.py, cover.py, sensor.py, binary_sensor.py)

PassiveBluetoothCoordinatorEntity.available treats any BLE address activity as "device present", so a shade that's been out of range for days keeps reporting its last-known position as gospel. I had a Baby Room Curtains motor showing state="closed" for three days after the BLE proxy lost it.

Fix: track _last_v2_ts on the coordinator. New data_available property is True iff a V2 advert decoded within STALE_AFTER = 300s. Cover/sensor/binary_sensor override available to AND with it. RSSI sensor is exempt because it tracks raw link activity.

4. home_id stickiness disables UI controls (coordinator.py + cover.py)

This one's a knock-on from fix #2 — surfaced rather than introduced.

cover.supported_features returns 0 when data["home_id"] is truthy and len(HOME_KEY) != 16. The intent is sensible: "we know encryption is needed but we don't have a key, so don't pretend the slider works." The old behaviour accidentally bypassed this because home_id was wiped every event — most of the time the gate didn't fire.

With self.data preserved, the gate now fires consistently, including on ACR roller shades that report home_id != 0 but in practice accept plaintext commands just fine (verified against my five ACR rollers). So the gate disables real-world-working controls.

Fix in this PR: home_id is consumed inline (drives api.encrypted) and popped from self.data so the gate doesn't see it. UI controls stay visible; the cipher is still applied when both home_id and a real 16-byte HOME_KEY are present.

Alternative I considered: drop the home_id check from supported_features entirely. Both approaches reach the same place. Happy to swap if you prefer the explicit removal.

The new feature: gateway-extracted home key

The HOME_KEY problem in const.py is the biggest practical pain. HACS overwrites it on every update — so users who took the time to extract their key by running scripts/extract_gateway3_homekey.py get silently broken once a month.

This PR moves the key to durable per-installation storage, and adds an in-app flow to extract it:

  • gateway.py — async aiohttp port of scripts/extract_gateway3_homekey.py. Probes a Gen3 gateway, walks the shade list, and asks each in turn for the home key. Any single successful GetShadeKey response is sufficient (all shades in one home share the key). Tolerates per-shade failures (offline, out of range from the gateway, etc.).

  • key_store.py — thin wrapper over homeassistant.helpers.storage.Store that persists the 16-byte key as hex in .storage/hunterdouglas_powerview_ble. Outside the integration source tree, so HACS updates leave it alone. The Store versioning is in place for future schema changes.

  • config_flow.py — top-level menu now offers:

    • Extract home key from a PowerView gateway — one-shot helper. Manual URL input, or auto-prefilled from zeroconf discovery. After a successful extraction, the flow aborts with home_key_saved, reloads every existing shade entry so they pick up the new key, and never creates an entry of its own. The gateway is only needed for that one call; after that it can be unplugged, taken offline, anything.
    • Pair a PowerView shade (Bluetooth) — your existing flow, unchanged.
  • Zeroconf discovery — manifest adds _PowerView-G3._tcp.local. and _powerview-g3._tcp.local. (Gen3 gateways advertise the mixed-case form in the wild; the lowercase variant is defensive). When a gateway appears on the network and no key is stored yet, the extraction flow is offered via Discovered tile. After a key is stored, the discovery handler aborts silently with home_key_already_saved to avoid spamming the UI on every rediscovery — re-extraction is always available from the integration menu.

  • coordinator.py — now accepts home_key as a constructor parameter (resolved from the store at setup time in __init__.py). const.HOME_KEY remains as a fallback (default b"") for users who haven't run the extractor yet; it still works as before if you put the key there manually.

The result: the user runs the extractor once, the key sits in .storage/, every shade picks it up at coordinator init, and HACS updates stop breaking encrypted shades.

Opinionated bits worth your review

These three I'd appreciate a call from you on:

  1. integration_type: "device""hub" in manifest. Strictly speaking each shade is still its own config entry, so "device" is defensible. I bumped it because the menu UX (manual key extraction) reads more like a hub-level operation, and "hub" makes the integration easier to discover in "Add Integration" without an existing BLE discovery. Easy to revert if you prefer.

  2. home_id pop workaround in coordinator.py vs. removing the gate from supported_features. Both restore controls; the pop is less invasive to the cover class but leaves a hidden side effect. Your call.

  3. Version bump to 0.24 — bumped because the config flow shape changed. You may want to use a different number or hold for a release-batched bump.

Compatibility

  • Existing shade entries continue to work unchanged. They were always at VERSION = 1, MINOR_VERSION = 0; this PR doesn't touch the schema or require migration.
  • Existing users with a hand-edited const.HOME_KEY continue to work because the store-lookup falls back to const.HOME_KEY when no stored key exists. They can keep their setup or migrate to the store on first restart by running the extractor.
  • Anyone with merged devices from bug Are you still using/developing this? #1 stays merged until they reset (the registry doesn't auto-unmerge). Their new entries register fresh MAC-based identifiers next to the bogus name-based ones, but the merge persists. I'm happy to add a one-shot cleanup utility if you want.

Testing

Verified against a 10-shade installation in Panama:

  • 5 ACR rollers (Kitchen Big, Kitchen Small, Master Bedroom Roller, Guest Room Roller, Baby Room Roller) — model 49, sw 399
  • 4 KDT curtain motors (Master Bedroom Curtains, Baby Room Curtains, Guest Room Curtains, KDT:4D59) — model unknown, sw 419 — these are the ones that strictly enforce encryption
  • 1 KDT:B428 — also sw 419

Pre-fix: KDT curtains silently timed out on every open/close command from HA (TimeoutError: Device did not send confirmation from api._cmd) while ACR rollers worked. Hand-editing const.HOME_KEY made the KDTs work too, but the next HACS update wiped it.

Post-fix:

  • Manual extraction flow tested via REST against gateway http://192.168.187.237. Returns the expected 16-byte key, persists to .storage/hunterdouglas_powerview_ble, aborts with home_key_saved.
  • Zeroconf advertised type confirmed as _PowerView-G3._tcp.local. via dns-sd -B.
  • Close → open cycle on one shade per room (guest bedroom, baby room, master bedroom, kitchen) all completed with HTTP 200 and state=open, pos=100 within ~30s, no TimeoutError in logs.
  • Coordinator data_available gate verified: stale shades transition to unavailable after the 5-minute window.
  • HA restart preserves the key and shades come back encrypted-capable without any manual step.

License

Submitted under the project's Apache 2.0 license per CONTRIBUTING.md.


Happy to split this into smaller PRs (e.g. bug fixes separately from the gateway flow) if that's easier to review. The fixes are independent — the only ordering constraint is that fix #4 needs to land with or after fix #2.

Frans-Willem and others added 3 commits May 24, 2026 19:12
…unt (patman15#30)

* Add scripts/get_home_key.py to fetch home keys from the HD cloud account

A fourth way to obtain the HOME_KEY: signs in to the PowerView account
API, exchanges the pvkey for a Firebase token, and reads the same
Firestore home document the Android app reads from. Works for any home
on the account whether or not a Gen3 gateway is reachable, and avoids
the SQLite-extraction route described in the HA forum thread.

The flow mirrors the Android app's (decompiled from
com.hunterdouglas.powerview 3.8.1):

  1. POST /api/v5/users/rcUserSignIn        -> pvkey
  2. GET  /api/v5/homes                     -> documentId per home
  3. GET  /api/v5/firebaseAuth/userToken    -> Firebase custom token
  4. Identity Toolkit signInWithCustomToken -> Firebase idToken
  5. Firestore homes/{documentId}           -> home.key

Empty and the sentinel '00112233445566778899AABBCCDDEEFF' are reported
as 'not set', matching the app's CloudDecoder behaviour.

README updated to list the new method alongside the existing three.

* Send the same identity fields the Android app does

The previous body fields (`deviceAppBrand: "PowerView"`, `deviceAppId:
"python-client"`, `deviceName: "python"`, etc.) and the default `requests`
User-Agent would stand out trivially on the Hunter Douglas server side.

Match what com.hunterdouglas.powerview 3.8.1 actually sends:
  - deviceAppBrand "HD"  (BrandConfig.getAppBrandName for the 'hd' flavor)
  - deviceAppId "<16 hex chars>hdrelease"  (BrandConfig.appDeviceId; 16 hex
    chars derived from sha256(email) for stability across runs)
  - deviceName / deviceType "Google Pixel 8"  (Build.MANUFACTURER+MODEL)
  - language/region from a fixed en_US locale
  - User-Agent "PowerView/3.8.1 7911 (Google Pixel 8 en_US)" on every
    Hunter Douglas request  (PowerViewApplication.getUserAgentString)

The User-Agent is now applied via a `requests.Session` shared by all three
HD endpoints. Firebase/Firestore calls keep the default User-Agent — those
endpoints aren't HD-controlled and the app uses the Firebase SDK there
anyway.
…xtraction

Bug fixes
- Device-registry merge from non-unique identifier: coordinator.device_info
  used (DOMAIN, self.name) where self.name is the BLE-advertised local
  name. When firmware fallback or transient mis-advertisement made
  multiple shades share a name (e.g. "AWEI T13 Pro"), HAs registry
  merged them into one device record and lost per-shade name_by_user and
  area assignment. Switched to (DOMAIN, format_mac(self.address)).
- Position wiped on every BLE event: self.data was reset to just
  {ATTR_RSSI: rssi} on every event, then conditionally merged with
  manufacturer data. Any non-V2 packet (or empty decode) left the cover
  with no position, surfacing state="open" with current_position=None
  even on closed shades. Now preserves last-known V2 fields and only
  updates them when a V2 advert decodes successfully; state-of-the-moment
  flags (is_opening/is_closing/battery_charging/etc.) are still cleared
  per-event so movement bits do not latch.
- home_id stickiness disabling controls: cover.supported_features
  returns 0 when home_id != 0 and HOME_KEY isn't 16 bytes. With the
  data-preservation fix above, this gate now correctly triggers, but
  many paired roller shades accept plaintext commands even without
  HOME_KEY, so the gate is over-protective. home_id is now consumed
  (drives api.encrypted) and popped from self.data so UI controls stay
  visible; cipher is applied only when both home_id and a real 16-byte
  HOME_KEY are present.
- Stale entities masquerading as fresh: PassiveBluetoothCoordinatorEntity
  treats any BLE address activity as "present", so an out-of-range
  shade kept reporting its last known position indefinitely. Added a
  data_available property on the coordinator (True iff V2 advert decoded
  within STALE_AFTER=300s) and overrode available in cover/sensor/
  binary_sensor to AND with it. RSSI sensor is exempt.

Gateway-driven home key extraction
- New gateway.py: async aiohttp port of scripts/extract_gateway3_homekey.py.
  Probes a PowerView Gen3 gateway over HTTP, walks the shade list, and
  asks each in turn for its home key via the GetShadeKey frame. Any
  single successful response is sufficient since all shades in one home
  share the same key.
- New key_store.py: thin wrapper over homeassistant.helpers.storage.Store
  that persists the 16-byte key as hex in
  .storage/hunterdouglas_powerview_ble. Outside the integration source
  tree, so HACS updates cannot wipe it.
- Config flow now offers a top-level menu: "Extract home key from a
  PowerView gateway" (one-shot helper that aborts without creating an
  entry) or "Pair a PowerView shade" (existing BLE pairing flow).
  Zeroconf discovery via _PowerView-G3._tcp. auto-triggers the
  extraction flow when a gateway appears on the network; skipped once a
  key is already stored to avoid Discovered-tile spam.
- Coordinator now accepts home_key as a parameter (resolved from the
  store at setup) rather than importing const.HOME_KEY directly.
  const.HOME_KEY remains as a final fallback (default b"").

manifest bumped to 0.24; integration_type changed from "device" to "hub"
to reflect the home-key extraction role.

Tested against a 10-shade installation with 4 KDT curtain motors (which
require encrypted commands) and 6 ACR rollers (which do not). All ten
respond to open/close after extraction; previously the KDT motors
silently timed out and const.py had to be hand-edited every HACS update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fallback was meant to give responsive UI during the brief window
between sending a BLE command and the first advert echoing the new
movement bits, but it has a much worse failure mode: _target_position
is never reset on successful command completion, and api.is_connected
flickers as BLE connections come and go after each command. The
combined result is that hours after a successful set_cover_position,
the entity still reports is_opening or is_closing based on a stale
target — which freezes the cached state on the optimistic write
("state=closed" with "current_position=100" for the rest of the
session) because subsequent state recomputations keep returning a
movement state instead of resolving to the actual position.

Both properties now use only the V2 advert movement bit. State during
a command transitions purely on advert evidence, which is what the
shade itself reports anyway. Verified end-to-end with HomeKit driving
sequential set_cover_position calls across 6 shades: every entity
settled cleanly to state matching its current_position, no stale
movement state lingered after the motors stopped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cloudore

Copy link
Copy Markdown
Author

Pushed a small follow-up to the head branch: a01d95c removes the _target_position fallback from is_opening / is_closing in cover.py.

I left the fallback in the original PR because it gives slightly more responsive UI during the brief window between sending a BLE command and the first advert echoing the movement bits. Running it end-to-end with HomeKit driving sequential set_cover_position calls across six shades exposed the cost: _target_position never resets on successful command completion, and api.is_connected flickers as BLE connections come and go. Hours after the shade physically settles, the entity still reports is_opening / is_closing based on the stale target. The cached state field then freezes on the optimistic write (state="closed" with current_position=100 was the visible symptom in HomeKit) because the next state recomputation keeps returning a movement state instead of resolving to the actual position.

Both properties now use only the V2 advert movement bit. The shade is the source of truth for whether it is moving — the integration should not second-guess it. Reverified: every entity settled cleanly to a state matching its current_position, with no stale movement state lingering after the motors stopped.

Happy to revert if you want the responsive-UI tradeoff back; the brief blank window during a command is the only regression and adverts come every 1–2s so it is not noticeable in practice.

Bit 0x04 of manufacturer-data byte 8 is set on shades that accept
encrypted BLE commands with error_code=0 but refuse to physically
move. Verified against a live installation: of 10 shades, the 2 that
silently failed every set_position call had this bit set, the 8 that
moved correctly had it clear. The bit clears once the motor has been
re-calibrated via the PowerView app or driven through its full range
with the physical remote.

Without this sensor the failure mode is invisible: HA service calls
return HTTP 200, the integration logs no error (the shade ACKs with
error_code=0), and the cover entity just sits at its last reported
position. Surfacing the flag as a binary_sensor with
device_class=problem lets users notice the broken motor immediately
and points them at the fix.

Diagnostic-category sensor so it doesn't clutter the dashboard for
healthy installations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cloudore

Copy link
Copy Markdown
Author

One more follow-up: 6a20683 exposes bit 0x04 of manufacturer-data byte 8 as a new diagnostic binary_sensor per shade — service_required (device_class=problem).

The bit was found while debugging two shades that wouldn't physically respond to set_cover_position. The shades:

  • accepted BLE connections (connect took 0s)
  • ACK'd the encrypted SET_POSITION frame with error_code=0 (the integration logged a clean round-trip)
  • and yet never moved — subsequent adverts kept reporting position 0

Comparing the manufacturer data byte-by-byte against eight working shades, the only consistent diff was byte 8: 0xd6 on the broken pair vs 0xd2 on every working one — a single set bit at 0x04 that the integration didn't decode. The bit clears once the motor is re-calibrated via the PowerView app or driven through its full range with the physical remote.

Without this sensor the failure mode is silent: HA returns HTTP 200, no error is logged anywhere, and the cover entity just sits at the last reported position. Users mostly blamed the integration. Now the service_required flag flips on immediately and points at the real cause.

Setting entity_category=DIAGNOSTIC so it stays out of the dashboard for healthy installations. Open to a clearer name — needs_calibration, position_unknown — depending on how confident you are about the semantics; I named it service_required because the only thing I've definitively observed is "shade is in a state where it won't move."

Cloudore and others added 2 commits May 24, 2026 21:38
Observed live: the bit transiently clears for ~10s when the motor is
actively tracking position (during a remote-triggered move), then
snaps back on as soon as the motor settles. So it indicates "motor
in low-power state", not "permanent calibration fault". The functional
implication for HA users is the same — BLE SET_POSITION commands sent
while the bit is on are ACK'd with error_code=0 but ignored — but the
docstring now reflects what we actually saw rather than guessing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empirically: shades whose advert reports bit 0x04 of byte 8 set (the
service_required flag) ACK SET_POSITION with error_code=0 but ignore it
physically. The pattern can be cleared by sending the same frame again
~500ms later in the same BLE session — the first frame wakes the motor
without moving anything; the second frame, sent while it is briefly
awake, actually drives the curtain.

This is what a physical remote effectively does: button press → motor
wakes → command processed. Without this, HA users have to physically
press the remote or recalibrate via the PowerView app every time a
shade slips into low-power state, even though the motor is otherwise
healthy (battery 100, BLE reachable, encryption key correct).

Implementation:

- api.set_position / api.open / api.close: new wake_first kwarg. When
  true, the SET_POSITION frame is sent twice with a 0.5s gap, both over
  the same BLE connection (disconnect=False on the first send). The
  wake-up send swallows BleakError / TimeoutError so the real send still
  runs even if the wake-up itself failed.

- cover.py: each command path checks self._coord.data["service_required"]
  via a new _needs_wake property and passes wake_first=True when set.
  Shades that don't have the bit set continue to get a single send with
  no added latency.

Verified against a curtain motor (KDT:D079) that had refused every
single HA-issued open/close for hours, reporting service_required=on
the entire time. After this change a single HK press transitioned the
shade from closed/0 to closing/47 within seconds and to open/100 in
~30s, with the bit clearing partway through the move as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cloudore

Copy link
Copy Markdown
Author

Final follow-up: d9a2d4d makes the integration self-heal for shades in the low-power state.

After exposing the service_required bit (previous commit) and watching one curtain that had refused every HA command for hours, the pattern became clear: shades whose advert reports bit 0x04 of byte 8 set ACK SET_POSITION with error_code=0 but ignore it physically. Sending the same frame again ~500ms later in the same BLE session fixes it — the first frame wakes the motor without moving anything; the second frame, sent while the motor is briefly awake, actually drives it. This is what a physical remote effectively does (button press → motor wakes → command processed), and matches the empirical observation that the bit clears for ~10s during a remote-driven move.

The implementation is gated on service_required so it adds zero latency to healthy shades:

  • api.set_position / open / close got a wake_first=False kwarg. When true, the SET_POSITION frame is sent twice with a 0.5s asyncio.sleep between, both inside the same BLE connection (disconnect=False on the first send). The wake-up swallows BleakError / TimeoutError so the real send still runs even if the wake itself failed.

  • cover.py adds a _needs_wake property that returns self._coord.data.get("service_required") and passes it through to every command path. Shades without the bit set continue to get a single send.

Live verification on KDT:D079, which had been stuck for hours:

Pre-state:           state=closed pos=0  service_required=on
HK open_cover:       HTTP 200 in 0.7s   ← two-stage send including 0.5s gap
Post-state (+5s):    state=closing pos=47
Post-state (+30s):   state=open pos=100

And the debug log shows the second send correctly reusing the open connection:

21:45:25.011 KDT:D079 setting position to 100/... (wake_first)
21:45:25.011 Connecting KDT:D079
21:45:25.163 received BLE data: d2 27 8f 91 16
21:45:25.664 KDT:D079 already connected           ← 0.5s later
21:45:25.703 received BLE data: d2 27 8c 91 16
... advert byte 8 flips from 0xd6 to 0xd2; current_position climbs 0 → 37 → 42 → 100

Same test on shades with service_required=off (Kitchen Big and the 4 other working rollers) shows no wake-first overhead — single send, same latency as before this PR.

End-state for the original integration user pain: any shade that slips into low-power mode auto-recovers on the next command, no remote-press or PowerView app trip required.

"Service required" implied user action when in practice the integration
auto-recovers via the wake_first send path. The bit is actually a
transient low-power / sleep flag — it cycles off during motor activity
and back on once the motor settles. Renamed end-to-end (decode key,
coordinator pop list, cover._needs_wake, binary_sensor key +
translation_key, English string) to "low_power_mode" / "Low power
mode". Dropped device_class=problem since it is no longer a problem
the user must respond to. Kept entity_category=diagnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cloudore

Copy link
Copy Markdown
Author

Renamed: 85b7151service_requiredlow_power_mode across decode key, coordinator, cover._needs_wake, binary_sensor entity, translation. "Service required" implied the user needed to take action; with the wake_first send path landed in the previous commit, the integration auto-recovers and the bit is purely diagnostic. Also dropped device_class=problem — it isn't a problem the user has to respond to. entity_category=diagnostic stays.

Sorry for the rename mid-PR. If you'd rather squash all six commits before merging that's fine by me — happy to rebase.

The V2 advert encodes is_opening / is_closing in pos & 0x3 (0x2 and 0x1
respectively). Empirically, at least some KDT curtain firmwares swap
those bit meanings vs ACR rollers — symptom was HomeKit displaying
"Closing" while a KDT curtain was physically opening, even though the
position field was climbing from 0 toward 100 the whole time.

Position itself is unambiguous. After updating self.data with the decoded
V2 fields, compare the new ATTR_CURRENT_POSITION against the previous one
and override is_opening / is_closing accordingly:
  * new > prev  ->  is_opening = True,  is_closing = False
  * new < prev  ->  is_opening = False, is_closing = True
  * new == prev ->  both False (shade is stopped)

The advert bits are now ignored for direction inference. The is_closed
property still drives the steady-state "open" vs "closed" classification.

Verified live on KDT:3F34 (Master Bedroom Curtains), which was
previously showing "Closing" in HomeKit while opening: post-fix the
state trace is closed/0 → opening/44 → opening/95 → open/100.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cloudore

Copy link
Copy Markdown
Author

One more: a3e97b4 — derive cover direction from position delta instead of trusting the advert's is_opening/is_closing bits.

Observed live in HomeKit: a KDT curtain (KDT:3F34) was being driven open by HA and HomeKit's accessory state displayed "Closing" the entire time the curtain was physically opening, then snapped to "Open" when the motor settled. Pulling the raw advert during that move:

KDT:D079 (during open command, position climbing 0 → 37)
manufacturer_data 2073 = b'"5E\xc9\x05\x00\x00\x00\xd2'
bytes[3:5] = 0xc9 0x05  → pos = 0x05c9 = 1481 → current_position = 37.0
pos & 0x3 = 1

pos & 0x3 == 1 is what the integration decodes as is_closing per dec_manufacturer_data. But the shade was opening — current_position went 0 → 37 → 95 → 100 over the next few seconds. So at least for some KDT firmwares, the encoding of the is_opening / is_closing bits is opposite from what the ACR rollers use. Trusting the bit produces the wrong state during the entire move; HomeKit dutifully renders it.

Position itself is unambiguous. After updating self.data with the decoded V2 fields, the coordinator now compares ATTR_CURRENT_POSITION against the previously-decoded value and overrides the two movement bits:

new > prev  → is_opening=True,  is_closing=False
new < prev  → is_opening=False, is_closing=True
new == prev → both False (motor stopped)

is_closed still drives the steady-state classification, so a settled shade reports open / closed correctly.

Verified post-fix on the same KDT:3F34: open command traces as closed/0 → opening/44 → opening/95 → open/100 instead of the previous closed/0 → closing/44 → closing/95 → open/100.

Trade-off: there is a one-advert latency before the first direction transition (the very first advert after the command has no _last_position to compare against, so movement bits stay False). In practice that is invisible because the next advert arrives within 1–2s with a non-zero position and the direction flips correctly.

If you know which shade families use which encoding I'm happy to make this a per-type lookup instead of a blanket override; otherwise this is the most robust fix that works across both flavors.

Two fixes that eliminate the "closed -> closing -> closed" and "open ->
opening -> open" ghost transitions observed during normal HomeKit use:

1. is_closed uses the same DIRECTION_DEADZONE (currently 2%) that the
   movement properties use, so motors that settle 1-2% short of fully
   closed (common end-of-travel calibration drift) no longer bounce
   between "open" and "closed". Now also correctly returns None when
   position is unknown, instead of falsely reporting "open".

2. The three command handlers no longer call async_write_ha_state() at
   the end of their BLE round-trip. The integration's coordinator
   already calls async_write_ha_state on every BLE advert via the
   PassiveBluetoothCoordinatorEntity update path, so position and state
   stay live. The optimistic post-BLE write was racy: a stale-position
   advert arriving during the ~500ms BLE round-trip caused the entity
   to write inconsistent state (e.g. "closed" at command time when
   target was 0 and position was momentarily reported as 0, before the
   real position re-asserted itself).

Verified live on Baby Room Roller (cover.acr_a67c_shade):
  pre: state=closed pos=0
  HK open(100): one brief "open" transient -> "opening" solid 20s with
  smooth position updates (0 -> 3 -> 6 -> ... -> 96) -> "open"/100.
  No more closed/closing/closed oscillations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cloudore

Copy link
Copy Markdown
Author

Major cleanup of the state machine: 8f20dcd. Replaces the position-delta direction inference from the previous commit with a target-vs-current model that's noise-free and matches user intent, plus kills the ghost state transitions.

Why the rewrite: the previous position-delta approach (commit a3e97b4) still flickered in practice because with multiple BLE proxies in one home, the same shade's adverts arrive interleaved out of order — even position bounces as small as ±3% produce visible state flips during a smooth open. Threshold filtering didn't help; the threshold needed to filter noise was the same magnitude as a normal advert delta. The cleaner approach is to derive direction from command intent.

Direction (cover.py)

is_opening / is_closing now read _target_position (set by every async_set_cover_position / async_open_cover / async_close_cover) and compare against current_cover_position:

DIRECTION_DEADZONE = 2
TARGET_TTL = 60.0  # seconds since last command

@property
def is_opening(self):
    if not self._target_is_fresh():
        return False
    if self.current_cover_position is None:
        return False
    return self._target_position > self.current_cover_position + self.DIRECTION_DEADZONE

Symmetric for is_closing. _target_is_fresh() returns False after TARGET_TTL seconds so a stale command can't make the entity report "opening" forever once the user has moved the shade with the physical remote.

Endpoint deadzone (cover.py)

is_closed uses the same deadzone:

@property
def is_closed(self) -> bool | None:
    pos = self.current_cover_position
    if pos is None:
        return None
    return pos <= CLOSED_POSITION + self.DIRECTION_DEADZONE

Stops the "open"/"closed" bounce when a motor settles at position 1 or 2 instead of exactly 0 (common end-of-travel drift). Also correctly returns None for unknown rather than falsely reporting "open".

Coordinator (coordinator.py)

The position-delta override is removed entirely. The coordinator still clears the advert's is_opening / is_closing bits per event (they're unreliable — KDT curtains and ACR rollers encode them inversely) so the cover entity falls through to the target-based properties.

Optimistic state write removed (cover.py)

The three command handlers no longer call async_write_ha_state() after the BLE round-trip. The PassiveBluetoothCoordinatorEntity update path already writes state on every BLE advert. The post-BLE optimistic write was racy: a stale-position advert landing during the ~500ms BLE window made the entity write inconsistent state (closed → closing → closed ghosts at the start of a close on a near-closed shade). Removing it eliminates the ghosts; the first real BLE advert after the command drives the visible transition.

Drain queue (api.py)

While I was in here: _cmd now loops to drain _cmd_next inside the lock. Previously, a second command that landed while a BLE write was in flight (e.g. user taps close in HomeKit while the shade is still opening) was silently dropped — the in-flight write completed, the lock released, and the queued command never ran. Now the lock-holder picks up the newer command on the same BLE session and sends it, so the second tap reliably interrupts the first. ~0.5s perceived latency from the queue drain plus the second BLE round-trip.

Live verification

Baby Room Roller (cover.acr_a67c_shade), full open cycle from closed/0:

+0.5s   state=open       pos=0   (one transient frame at the very start)
+1.0s   state=opening    pos=0
+1.5s   state=opening    pos=3
+5.0s   state=opening    pos=20
+10.0s  state=opening    pos=44
+15.0s  state=opening    pos=69
+20.0s  state=opening    pos=94
+21.0s  state=open       pos=100

Smooth position climb every 0.5s, single direction state throughout the move, settles to open at the target. Same shape for closes.

The one-frame open at +0.5s is an artifact of the entity getting an early BLE advert before async_set_cover_position's _set_target runs — could be eliminated by reordering _set_target before the early-return check, but it's a single transient and HomeKit doesn't display it.

…tion stable

HA's HomeKit Bridge resets the HomeKit accessory's TargetPosition to
CurrentPosition whenever the cover entity is *not* in a moving state
(see homeassistant.components.homekit.type_covers.OpeningDevice.async_update_state).
The previous patch removed the post-BLE optimistic write to kill ghost
"closed -> closing -> closed" transitions; the side effect was that
during the ~500ms between async_set_cover_position being entered and
the next BLE advert arriving, the entity could appear in HA as state
"open" or "closed" with the new target already set. The Bridge sees
that brief non-moving frame, clobbers HK's TargetPosition to match
CurrentPosition, and from that point onward iOS computes direction
from (Target - Current) and displays it backwards for the rest of
the move — e.g. "Closing..." for the entire physical open.

The fix is to write state once at the moment the command is registered,
*before* the BLE await. With _target_position set, is_opening / is_closing
already report the correct direction, so the entity immediately enters
"opening" or "closing" and stays there until the next BLE advert
recomputes (and stays in the same moving state because target hasn't
been reached yet). The Bridge never sees a non-moving frame during the
move, never overrides Target, and iOS displays the user's intent.

Verified live on Master Bedroom Curtains (cover.kdt_3f34_shade) from
state=closed/0 to fully open:

  +0.5s   state=opening pos=0      <-- immediate, before BLE round-trip
  +1.0s   state=opening pos=2
  +5.0s   state=opening pos=45
  +10.0s  state=opening pos=93
  +11.0s  state=open    pos=98     <-- within DIRECTION_DEADZONE of target
  +11.5s  state=open    pos=100

No "open" transient between command and "opening", which was the frame
the HomeKit Bridge was latching onto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cloudore

Copy link
Copy Markdown
Author

Found the root cause of the "Closing..." while opening report in HomeKit: 81c2af8. The previous commit removed the post-BLE optimistic write to kill ghost state transitions, which exposed an interaction with HA's HomeKit Bridge I hadn't accounted for.

In homeassistant.components.homekit.type_covers.OpeningDevice.async_update_state:

if isinstance(current_position, (float, int)):
    current_position = int(current_position)
    self.char_current_position.set_value(current_position)
    # Writing target_position on a moving cover
    # will break the moving state in HK.
    if new_state.state not in MOVING_STATES:
        self.char_target_position.set_value(current_position)

Every time HA reports the cover in a non-moving state, the Bridge clobbers HK's TargetPosition characteristic to match CurrentPosition. iOS HomeKit derives the direction label from (Target - Current), not from PositionState, so once Target gets reset to 0 (current position when shade was closed and command first lands), every subsequent CurrentPosition update of 1, 2, ..., 100 reads as "going down from current toward target=0", which iOS displays as "Closing..." — for the entire physical open.

The window for the Bridge to clobber Target is small but real: between the cover entity's async_set_cover_position running and the first BLE advert that triggers a state recomputation in a moving state, the entity passes through "open" or "closed" briefly (target set, but no advert-driven state write yet). HA's state machine fires a state-changed event for that frame, the Bridge processes it, and Target is lost.

Fix is to write state before the BLE await:

self._set_target(round(target_position))
self.async_write_ha_state()   # <-- new: state="opening"/"closing" immediately
try:
    await self._coord.api.set_position(round(target_position), wake_first=self._needs_wake)
except BleakError as err:
    ...

With _target_position set, is_opening / is_closing already report the right direction (they use target-vs-current), so the entity immediately enters the correct moving state. The Bridge never sees a non-moving frame during the move, never resets Target, iOS displays the user's intent.

The post-BLE write is still removed — only the pre-BLE write was needed.

Live verification

Master Bedroom Curtains, full open from closed:

+0.5s   state=opening pos=0      <-- written immediately, before BLE round-trip
+1.0s   state=opening pos=2
+5.0s   state=opening pos=45
+10.0s  state=opening pos=93
+11.0s  state=open    pos=98     <-- within DIRECTION_DEADZONE of target=100
+11.5s  state=open    pos=100

No transient "open" frame between command and "opening". HomeKit no longer sees the cover enter a non-moving state mid-command, so the Bridge keeps the user's TargetPosition intact and iOS displays "Opening..." throughout the move (and "Closing..." for closes).

Cloudore and others added 2 commits May 24, 2026 23:59
…pinned

Adds two defenses against the race where a BLE advert arriving in the
same event-loop tick as a HomeKit set_cover_position would otherwise
write a non-moving 'open'/'closed' frame that trips HA's HomeKit Bridge
into clobbering HK's TargetPosition (which then makes iOS display the
direction backwards for the rest of the move).

1. _pinned_direction attribute and _pin_direction(STATE_OPENING |
   STATE_CLOSING | None) helper. is_opening and is_closing check the
   pin first and return True unconditionally when set. Pin is set at
   the top of every command handler and cleared in a finally block.

2. Overridden _handle_coordinator_update that short-circuits while a
   pin is set, so BLE adverts arriving mid-command can't fire an
   async_write_ha_state that would overwrite the entity's pinned
   moving state.

Also widens is_closed deadzone (ENDPOINT_DEADZONE = 5) so brief
position-noise adverts that report 3-4 from a settled-closed shade
don't flip the entity from 'closed' to 'open' right before a command.

NOTE: when the advert handler is scheduled in the event loop *before*
the command handler, the first non-moving frame still gets written
before _pin_direction has been called. The cluster of state writes
that result are within microseconds of each other and HK Bridge's
Target reset still happens on the leading 'open'/'closed' frame.
This commit shrinks but does not fully eliminate the window. See
PR thread for follow-up discussion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HA's HomeKit Bridge fires the `homekit_state_change` event SYNCHRONOUSLY
inside its TargetPosition setter callback, before the
cover.set_cover_position service-call task is queued onto the event
loop. Subscribing to that event from the cover entity lets us pin the
direction and pre-set the target in the same synchronous frame that
iOS triggered the command from, beating any racing BLE advert that
would otherwise fire async_write_ha_state with a non-moving state and
trip the Bridge into resetting HK's TargetPosition (which flips iOS'
direction label for the entire move).

Only set_cover_position events on this entity are handled; everything
else is ignored. The pin is still released in the command method's
finally block — the listener just guarantees it is set early enough.

No HA core changes — the event listener lives entirely inside the
integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@patman15

Copy link
Copy Markdown
Owner

Isn't that PR similar to #29 ?

Cloudore and others added 4 commits May 25, 2026 05:06
Previously `available` was gated on `data_available`, which checks
that a V2 advert was decoded within STALE_AFTER (30 min). ACR rollers
back off their advert cadence after a few minutes of idle and may
not transmit again for hours — they still accept BLE writes the
entire time and wake on command. Gating availability on advert
freshness made HA refuse to dispatch service calls when the user
tapped the shade in HomeKit / Lovelace / a script, logging:

  WARNING (MainThread) [homeassistant.helpers.service] Referenced
  entities cover.acr_3cf2_shade are missing or not currently available

When the `homekit_state_change` listener fired and pinned direction
but the service call was dropped, the pin had no command-method
finally block to clear it, leaving the entity stuck at "Opening..."
in HomeKit and the motor unmoved.

Fix splits the concern:

  - `available` now only checks `super().available` (BLE address
    reachable). The shade is always controllable.
  - `assumed_state = True` when data is stale, so HA's UI shows the
    "assumed" indicator without blocking commands.

Also adds `_pin_is_valid` as a defense-in-depth backstop: the pin is
honored only while `_target_is_fresh()` (i.e. within TARGET_TTL of
the last command). If anything else slips a stuck pin past the
command method's finally (unhandled exception, future code paths),
the entity falls back to advert-derived state after 60s instead of
showing "Opening..." forever.

Removed three blocks of debug logging that were left behind from
diagnosing the earlier race (per-property is_opening / is_closing
/ is_closed debug + traceback dump in async_write_ha_state). Kept
the listener; trimmed its docstring to a single block.

No HA core changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously is_opening / is_closing returned True only while
|target - current| > DIRECTION_DEADZONE (2%). On a 0->100 open, that
flipped to False the moment the motor crossed pos=98, leaving a "open"
state frame written while the motor was still physically running its
last few percent. HA's HomeKit Bridge clobbers HK's TargetPosition
whenever the entity is reported in a non-MOVING state (open/closed),
so that single transient frame at end-of-move flipped iOS direction
label backwards for the rest of the move.

Replaces the deadzone with a "motor settled" check:
- record the wall-clock timestamp of every position change in
  _handle_coordinator_update
- is_opening / is_closing return True while target != current AND
  _has_settled() is False (last position change within 1.5s)
- once the position is stable, fall through to the normal open/closed
  evaluation

Live trace, Guest Room Roller 0->100:

  closed -> opening (call_service)
  ... 25 advert frames at "opening" while position rises 0->100 ...
  open (settled)

No "open" or "closed" transient between the moving state and the
final state. TargetPosition stays whatever iOS asked for, direction
label stays correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous version of _has_settled treated a shade that hadn't moved in
hours as "settled" the instant a fresh command landed. The next BLE
advert (still at the pre-command position) would arrive after the
command method's pin had been released, fall through is_opening /
is_closing to is_closed, and write a non-moving "open"/"closed" frame
*while the motor was just about to start*. HA's HomeKit Bridge
clobbers HK's TargetPosition on any non-moving frame, flipping iOS
direction label backwards for the rest of the move.

Fix: _has_settled now returns False if _last_position_change_ts is
older than _target_set_at. The motor hasn't reacted to the command
yet, so we are explicitly NOT settled — keep is_opening / is_closing
returning True until the first post-command position change starts
the SETTLED_AFTER countdown.

Diagnostic trace (Kitchen Small open from closed, before fix):
  T+0.000  state=opening pos=0  (my command method)
  T+0.845  state=closed  pos=0  (BLE advert + ghost!)
  T+0.958  state=closed  pos=0  (BLE advert + ghost!)
  T+1.178  state=opening pos=2  (motor started, recovers)

After fix:
  T+0.000  state=opening pos=0  (command method)
  T+0.451  state=opening pos=0  (BLE advert, NOT settled — pos change
                                  ts < target_set_at, returns False)
  T+0.670  state=opening pos=0  (same)
  T+0.780  state=opening pos=2  (motor started)
  ...                            (continuous opening through pos=100)
  state=open                     (settled after motor stops)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shade BLE module occasionally emits a stray pos=0 init/boot advert
(resetClock=False) when the motor settles, and flaky BLE proxies can
re-broadcast a stale all-zeros beacon mid-motion. Either source lands
a single advert with a physically impossible position delta between
two real frames.

On a 100->70 close, that frame was triggering:

  T+0.000  state=closing pos=100  (command)
  T+0.044  state=closing pos=100  (advert, motor not moving yet)
  T+0.096  state=closing pos=100  (advert)
  T+7.893  state=opening pos=0    <-- spurious advert, real motor at ~70
  T+8.113  state=open    pos=70   (good advert, motor settled at 70)

The "opening pos=0" frame fires because target (70) > current (0), so
is_opening returns True. HA's HomeKit Bridge stamps the HK
PositionState characteristic as INCREASING for that frame and iOS
latches the "Opening..." spinner until the next motion - despite the
shade being correctly settled at 70 in HA itself.

Reject single-advert jumps larger than MAX_POSITION_JUMP (50%):
- 50% is well above plausible motor motion between adverts (~7%/sec
  for ACR rollers means even a 5-second gap caps real change at ~35%)
- Rejected adverts don't update _last_position so the next good
  advert is still judged against the last *good* value (e.g. 100,
  not the rejected 0)
- We return early before super()._handle_coordinator_update so no
  state write fires for the spurious frame - HK never sees the wrong
  direction

Behavior unchanged for the common case: every test run that didn't
hit a spurious advert continues to write state cleanly through the
move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Cloudore

Copy link
Copy Markdown
Author

Isn't that PR similar to #29 ?

Sorry if it is, I just went through making the experience "Native" with Bluetooth Proxies and HomeKit and did not look at the PRs, just iterated (with Claude, admittedly) through versions until the whole thing felt less shaky. My setup feels solid now.

@safepay

safepay commented Jun 21, 2026

Copy link
Copy Markdown

Isn't that PR similar to #29 ?

I don't have any problems with people repeating my work in AI. I might just separate my work in to a separate repository as it is a refactor that does not allow migration. Given the need to handle the key, I simply choose a more standard HA method for doing this that is the same as other integrations with similar requirements.

I also assumed that there are not that many blinds in a house (vs smart switches), so recreating them would not be a problem.

Just let me know if you want to take the refactor approach that sets up a "virtual hub" for management and auto-discovery or go with this iterative approach that retains the current architcture.

@patman15

Copy link
Copy Markdown
Owner

Ok, I need to take a look, to see the delta of both PRs. In general I do not trust AI on good architecture. 😎

@safepay

safepay commented Jun 24, 2026

Copy link
Copy Markdown

Ok, I need to take a look, to see the delta of both PRs. In general I do not trust AI on good architecture. 😎

The difference lies fundamentally in the architecture. I took the approach of getting the architetecture aligned with more modern HA practices and sacrificing the migration option given the early state of the repository to try and allow myself and others to collaborate on a proven architectural base, while this PR fixes the outstanding issues but sacrifices long-term structural maintainability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BT support over standard BT adapter in HA

4 participants