Fix device merge, position wipe, staleness, home_id gate; add gateway-driven home key extraction#34
Fix device merge, position wipe, staleness, home_id gate; add gateway-driven home key extraction#34Cloudore wants to merge 16 commits into
Conversation
…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>
|
Pushed a small follow-up to the head branch: 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 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 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>
|
One more follow-up: The bit was found while debugging two shades that wouldn't physically respond to
Comparing the manufacturer data byte-by-byte against eight working shades, the only consistent diff was byte 8: 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 Setting |
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>
|
Final follow-up: After exposing the The implementation is gated on
Live verification on KDT:D079, which had been stuck for hours: And the debug log shows the second send correctly reusing the open connection: Same test on shades with 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>
|
Renamed: 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>
|
One more: 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:
Position itself is unambiguous. After updating
Verified post-fix on the same KDT:3F34: open command traces as Trade-off: there is a one-advert latency before the first direction transition (the very first advert after the command has no 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>
|
Major cleanup of the state machine: Why the rewrite: the previous position-delta approach (commit Direction (cover.py)
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_DEADZONESymmetric for Endpoint deadzone (cover.py)
@property
def is_closed(self) -> bool | None:
pos = self.current_cover_position
if pos is None:
return None
return pos <= CLOSED_POSITION + self.DIRECTION_DEADZONEStops 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 Coordinator (coordinator.py)The position-delta override is removed entirely. The coordinator still clears the advert's Optimistic state write removed (cover.py)The three command handlers no longer call Drain queue (api.py)While I was in here: Live verificationBaby Room Roller (cover.acr_a67c_shade), full open cycle from Smooth position climb every 0.5s, single direction state throughout the move, settles to The one-frame |
…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>
|
Found the root cause of the "Closing..." while opening report in HomeKit: In 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 The window for the Bridge to clobber Target is small but real: between the cover entity's 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 The post-BLE write is still removed — only the pre-BLE write was needed. Live verificationMaster Bedroom Curtains, full open from closed: 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). |
…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>
|
Isn't that PR similar to #29 ? |
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>
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. |
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. |
|
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. |
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-editconst.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_infowas returning(DOMAIN, self.name)as one of the registry identifiers.self.nameis 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 throughdevice_infoon the same coordinator pass, HA's device registry merges them into a single device record because they share an identifier. The user'sname_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_registryedit (split the merged record into N records, reassign entities by the MAC encoded in theirunique_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)On any event where
dec_manufacturer_datareturns[](advert without a V2 payload — happens during connect, scan responses, some BLE proxies),self.dataends up with onlyrssi. Then:current_cover_positionresolves toNoneis_closedreturnsNone == 0⇒Falseis_opening/is_closingfall through their guardsCoverEntity.statesettles onSTATE_OPENSo a closed shade flickers to
state="open", current_position=Noneevery 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_RSSIunconditionally; 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.availabletreats 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 showingstate="closed"for three days after the BLE proxy lost it.Fix: track
_last_v2_tson the coordinator. Newdata_availableproperty isTrueiff a V2 advert decoded withinSTALE_AFTER = 300s. Cover/sensor/binary_sensor overrideavailableto AND with it. RSSI sensor is exempt because it tracks raw link activity.4.
home_idstickiness disables UI controls (coordinator.py+cover.py)This one's a knock-on from fix #2 — surfaced rather than introduced.
cover.supported_featuresreturns0whendata["home_id"]is truthy andlen(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 becausehome_idwas wiped every event — most of the time the gate didn't fire.With
self.datapreserved, the gate now fires consistently, including on ACR roller shades that reporthome_id != 0but 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_idis consumed inline (drivesapi.encrypted) and popped fromself.dataso the gate doesn't see it. UI controls stay visible; the cipher is still applied when bothhome_idand a real 16-byteHOME_KEYare present.Alternative I considered: drop the
home_idcheck fromsupported_featuresentirely. Both approaches reach the same place. Happy to swap if you prefer the explicit removal.The new feature: gateway-extracted home key
The
HOME_KEYproblem inconst.pyis the biggest practical pain. HACS overwrites it on every update — so users who took the time to extract their key by runningscripts/extract_gateway3_homekey.pyget 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 ofscripts/extract_gateway3_homekey.py. Probes a Gen3 gateway, walks the shade list, and asks each in turn for the home key. Any single successfulGetShadeKeyresponse 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 overhomeassistant.helpers.storage.Storethat persists the 16-byte key as hex in.storage/hunterdouglas_powerview_ble. Outside the integration source tree, so HACS updates leave it alone. TheStoreversioning is in place for future schema changes.config_flow.py— top-level menu now offers:aborts withhome_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.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 withhome_key_already_savedto avoid spamming the UI on every rediscovery — re-extraction is always available from the integration menu.coordinator.py— now acceptshome_keyas a constructor parameter (resolved from the store at setup time in__init__.py).const.HOME_KEYremains as a fallback (defaultb"") 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:
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.home_idpop workaround in coordinator.py vs. removing the gate fromsupported_features. Both restore controls; the pop is less invasive to the cover class but leaves a hidden side effect. Your call.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
VERSION = 1, MINOR_VERSION = 0; this PR doesn't touch the schema or require migration.const.HOME_KEYcontinue to work because the store-lookup falls back toconst.HOME_KEYwhen no stored key exists. They can keep their setup or migrate to the store on first restart by running the extractor.Testing
Verified against a 10-shade installation in Panama:
49, sw 399unknown, sw 419 — these are the ones that strictly enforce encryptionPre-fix: KDT curtains silently timed out on every open/close command from HA (
TimeoutError: Device did not send confirmationfromapi._cmd) while ACR rollers worked. Hand-editingconst.HOME_KEYmade the KDTs work too, but the next HACS update wiped it.Post-fix:
http://192.168.187.237. Returns the expected 16-byte key, persists to.storage/hunterdouglas_powerview_ble, aborts withhome_key_saved._PowerView-G3._tcp.local.viadns-sd -B.state=open, pos=100within ~30s, noTimeoutErrorin logs.data_availablegate verified: stale shades transition tounavailableafter the 5-minute window.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.