Skip to content

fix: fall back to CipherV2 when Discovery can't decrypt a scan reply with CipherV1 - #177

Open
jitsumi wants to merge 1 commit into
cmroche:masterfrom
jitsumi:fix/discovery-cipherv2-fallback
Open

fix: fall back to CipherV2 when Discovery can't decrypt a scan reply with CipherV1#177
jitsumi wants to merge 1 commit into
cmroche:masterfrom
jitsumi:fix/discovery-cipherv2-fallback

Conversation

@jitsumi

@jitsumi jitsumi commented Jul 21, 2026

Copy link
Copy Markdown

Behavior change

Discovery.datagram_received only ever decrypts scan replies with the
generic CipherV1 (AES-ECB) cipher, unlike Device.bind() which already
tries CipherV1 then falls back to CipherV2 (AES-GCM). Devices whose
firmware uses the newer GCM-only protocol for their scan reply (observed
on retrofitted and newer Gree Wi-Fi modules) fail to decrypt with
CipherV1, raising an unhandled ValueError inside the asyncio UDP
callback (Data must be aligned to block boundary in ECB mode), and are
silently dropped from discovery — they never appear in discovery.devices
even though they're on the network and respond to the scan.

This PR extracts the one-line decrypt call in
DeviceProtocolBase2.datagram_received (network.py) into a small
_decrypt_pack() hook, then has Discovery override just that hook to
try CipherV1 then CipherV2, mirroring the existing fallback already in
Device.bind(). Discovery._decrypt_pack returns None when a reply is
undecryptable under both ciphers, which packet_received's existing
"unexpected response" handling already covers — no new error-handling
code needed, and it's strictly better than today's behavior (a quiet,
already-existing log line instead of an unhandled exception).

Why scope the fix this way

Two narrower/differently-shaped fixes were considered and rejected:

  • Patching CipherV1.decrypt() to silently retry as CipherV2 on
    failure. Rejected: it would change what "CipherV1" means for every
    caller in the codebase, not just discovery.
  • Adding the two-cipher retry logic directly inside the shared
    DeviceProtocolBase2.datagram_received
    , rather than via an
    overridable _decrypt_pack() hook. Rejected: that method is used by
    both Discovery (scanning for unknown devices, where the correct
    cipher genuinely isn't known yet) and Device (an already-bound device
    with an established, known-correct cipher). A bound Device shouldn't
    silently retry with a different cipher on every decrypt failure on an
    established connection — that risks masking real transmission errors
    and adds an extra decrypt attempt to the hot path of every status poll,
    for a case that should never legitimately happen post-bind.

_decrypt_pack() keeps Device's code path provably unaffected (it
doesn't override the hook, so it still calls self._cipher.decrypt()
directly, identical to before) while letting Discovery override just the
part that varies. It also avoids duplicating datagram_received's
surrounding logic, so any future change to that shared logic (parsing,
logging, dispatch) automatically applies to Discovery too instead of
needing to be kept in sync by hand across two copies.

Context / device info

Verified against 5 real Gree/Saunier Duval AC units on a live home
network (1 built-in Wi-Fi unit, 4 retrofitted Wi-Fi modules). Before this
fix, only the 1 built-in-Wi-Fi unit was discoverable; the other 4 sent
valid scan replies but were never found. After the fix, all 5 are
discovered and bind successfully. This appears to be the same root cause
reported (without a fix) in:

Also deployed and verified end-to-end inside an actual Home Assistant
instance (as a drop-in-replacement custom_component built from this exact
patch): all 5 units discovered and controllable after the fix, confirmed
stable across a restart.

Test plan

python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt pytest pytest-asyncio pytest-cov
pytest --cov-report=xml --cov=greeclimate tests/
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
  • Full suite: 110 passed (108 existing + 2 new), no regressions.
  • Added test_discover_devices_cipherv2_fallback (reproduces the bug:
    fails with the exact ValueError above when the fix is reverted, passes
    with it) and test_discover_devices_undecryptable_reply_ignored
    (confirms a reply undecryptable under both ciphers is discarded
    gracefully and doesn't block discovery of other devices).
  • flake8 --select=E9,F63,F7,F82: 0 errors on changed files. The
    --max-complexity=10 --max-line-length=127 informational check reports
    the same pre-existing findings (same issue types, shifted line numbers)
    as unpatched master — nothing new introduced.
  • Also manually re-verified end-to-end against the real hardware described
    above (discovery + bind + state read) with this exact patch, and inside
    an actual Home Assistant instance.

@jitsumi
jitsumi marked this pull request as draft July 21, 2026 15:04
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.02%. Comparing base (47b9850) to head (857e297).
⚠️ Report is 18 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #177      +/-   ##
==========================================
+ Coverage   95.71%   96.02%   +0.31%     
==========================================
  Files           8        8              
  Lines         770      780      +10     
==========================================
+ Hits          737      749      +12     
+ Misses         33       31       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…with CipherV1

Discovery.datagram_received only ever decrypts scan replies with the
generic CipherV1 (AES-ECB) cipher, unlike Device.bind() which already
tries CipherV1 then falls back to CipherV2 (AES-GCM). Devices whose
firmware uses the newer GCM-only protocol for their scan reply (observed
on retrofitted and newer Gree Wi-Fi modules) fail to decrypt with
CipherV1, raising an unhandled ValueError inside the asyncio UDP
callback, and are silently dropped from discovery.

Extract the one-line decrypt call in DeviceProtocolBase2.datagram_received
(network.py) into a small _decrypt_pack() hook, so Discovery only needs to
override that instead of duplicating the surrounding guard/parse/log/
dispatch logic. Discovery._decrypt_pack tries CipherV1 then CipherV2,
returning None when a reply is undecryptable under both, which
packet_received's existing "unexpected response" handling already covers.

Device is unaffected: it doesn't override _decrypt_pack, so its code path
through datagram_received is unchanged.

Verified against 5 real Gree/Saunier Duval units on a live network: 4 of
5 were never discoverable before this fix (their scan replies are
GCM-only), all 4 are found and bind successfully after it. Related
reports of the same symptom: home-assistant/core#82452,
home-assistant/core#148458.
@jitsumi
jitsumi force-pushed the fix/discovery-cipherv2-fallback branch from afc7396 to 857e297 Compare July 21, 2026 16:04
@jitsumi
jitsumi marked this pull request as ready for review July 21, 2026 16:08
@jitsumi

jitsumi commented Jul 23, 2026

Copy link
Copy Markdown
Author

@cmroche Hello
I'm taking the liberty of pinging you to let you know about this PR 🙏
And thanks for the repo

@jitsumi

jitsumi commented Aug 5, 2026

Copy link
Copy Markdown
Author

@cmroche
Hello 👋
Can you please review this PR please 🙏

@Hookookoo

Hookookoo commented Aug 6, 2026

Copy link
Copy Markdown

My AC controls stopped working about six months ago after upgrading green firmware. If this piece of code would fix that, I would also be grateful if it would be published.

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.

2 participants