Skip to content

Release v2.7.0 - #16

Merged
telard-pixel merged 5 commits into
mainfrom
dev
Jun 16, 2026
Merged

Release v2.7.0#16
telard-pixel merged 5 commits into
mainfrom
dev

Conversation

@telard-pixel

@telard-pixel telard-pixel commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Automated release PR for v2.7.0.

Summary by CodeRabbit

  • Chores
    • Updated integration version to 2.7.0.
    • Updated manifest dependencies to use the newer vendored approach (replacing the prior pinned library).
  • Bug Fixes
    • Improved MQTT behavior and log noise handling to better match the vendored client.
    • Enhanced robustness around authentication/data fetching for the hOn connection.
  • Documentation
    • Added/updated vendoring metadata to reflect how the bundled library was generated.
  • Tests
    • Adjusted tests to target the vendored library namespace.

tis24dev added 4 commits June 16, 2026 14:02
pyhon (upstream Andre0512/pyhOn, MIT) is unmaintained since Dec 2024 and is
our single point of failure. Vendor it in-tree so we control the client layer
and can patch it without depending on the abandoned PyPI release.

- Copy pyhon into custom_components/haier_hon/_vendor/pyhon/, with all internal
  imports rewritten from `pyhon` to the namespaced
  `custom_components.haier_hon._vendor.pyhon` (also the dynamic appliance import
  string). Keeps it fully isolated: no collision with a vanilla pyhon install.
- hon_client.py imports Hon / HonParameterEnum from ._vendor.pyhon.
- logging_utils.py: the pyhon loggers use __name__, so the MQTT noise logger is
  now the namespaced path; update MQTT_NOISE_LOGGERS accordingly.
- manifest.json: drop `pyhon==0.17.5`; add pyhon's runtime deps that HA does not
  already provide (awsiotsdk, yarl, typing-extensions). aiohttp comes from HA.
- LICENSE of pyhon retained next to the vendored package (MIT attribution).

Canonical source is the maintained private fork telard-pixel/pyhon.
- scripts/vendor_pyhon.py: regenerate custom_components/haier_hon/_vendor/pyhon
  from the maintained private fork telard-pixel/pyhon, rewriting imports to the
  namespaced package and recording provenance in _vendor/VENDOR.md. This is the
  canonical way to resync the vendored copy; do not hand-edit _vendor.
- Resync _vendor from fork f368d46, which adds an explicit WARNING in
  load_appliances when the hOn cloud returns 0 appliances for an account whose
  request/auth succeeded (almost always an account-side ownership/sharing state,
  not a client bug). Makes the "0 devices" case diagnosable from the HA log.
Pulls the flake8/mypy cleanup (comment wrapping + value-setter type widening)
from the maintained fork; no runtime behavior change.
…pace

The vendored pyhon lives under custom_components.haier_hon._vendor.pyhon, so its
loggers (via __name__) and the enum module the patch imports are namespaced.
Update the MQTT logger-name assertion and stub the vendored module chain in
sys.modules (with teardown cleanup) so the enum-patch test stays hermetic.
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f16ec5ce-d807-4d71-bf54-54a9b2f8725b

📥 Commits

Reviewing files that changed from the base of the PR and between 396a196 and 74b0c71.

📒 Files selected for processing (3)
  • custom_components/haier_hon/_vendor/VENDOR.md
  • custom_components/haier_hon/_vendor/pyhon/parameter/base.py
  • custom_components/haier_hon/_vendor/pyhon/parameter/enum.py

📝 Walkthrough

Walkthrough

Replaces the external pyhon==0.17.5 dependency with a fully vendored copy of the pyhon library under custom_components/haier_hon/_vendor/pyhon/. A new scripts/vendor_pyhon.py script clones/copies the source, rewrites all internal imports to the integration namespace, and generates VENDOR.md metadata. The manifest is updated to 2.7.0 with awsiotsdk, yarl, and typing-extensions as direct dependencies. Integration wiring in hon_client.py, logging_utils.py, and tests is updated to reference the vendored path.

Changes

Vendored pyhon library and integration wiring

Layer / File(s) Summary
Vendoring script, manifest, and metadata
scripts/vendor_pyhon.py, custom_components/haier_hon/manifest.json, custom_components/haier_hon/_vendor/__init__.py, custom_components/haier_hon/_vendor/VENDOR.md, custom_components/haier_hon/_vendor/pyhon/LICENSE
vendor_pyhon.py clones/copies the pyhon fork, rewrites imports to the integration namespace, sanity-checks for leftover references; manifest bumped to 2.7.0, replacing pyhon==0.17.5 with awsiotsdk, yarl, and typing-extensions; vendor metadata and MIT license written.
Shared constants, exceptions, helper, typedefs, and attributes
custom_components/haier_hon/_vendor/pyhon/const.py, custom_components/haier_hon/_vendor/pyhon/exceptions.py, custom_components/haier_hon/_vendor/pyhon/helper.py, custom_components/haier_hon/_vendor/pyhon/typedefs.py, custom_components/haier_hon/_vendor/pyhon/attributes.py
API endpoint constants, five custom exception classes, str_to_float helper, Callback/Parameter type aliases, and HonAttribute with time-based locking, ISO timestamp parsing, and value coercion.
Parameter type hierarchy
custom_components/haier_hon/_vendor/pyhon/parameter/*
HonParameter base with trigger system; HonParameterEnum with clean_value normalization; HonParameterFixed with empty-to-"0" fallback; HonParameterRange with step-aligned bounds validation; HonParameterProgram with category-derived values and prCode→name ids mapping.
Rule engine
custom_components/haier_hon/_vendor/pyhon/rules.py
HonRule dataclass and HonRuleSet that parse nested rule dicts, expand multi-value and extra-condition triggers, avoid self-referential assignments, and wire parameter.add_trigger callbacks applying _apply_fixed and _apply_enum mutations.
HonCommand and HonCommandLoader
custom_components/haier_hon/_vendor/pyhon/commands.py, custom_components/haier_hon/_vendor/pyhon/command_loader.py
HonCommand builds parameters by typology, patches rules, executes async send/send_specific/send_parameters with auth-failure handling; HonCommandLoader concurrently fetches definitions/history/favourites, builds the command map, restores last-run state, patches favourites with fixed parameters.
HonAppliance and device-type extensions
custom_components/haier_hon/_vendor/pyhon/appliance.py, custom_components/haier_hon/_vendor/pyhon/appliances/*
HonAppliance with dot-notation indexing, zone-aware identity, async load lifecycle, and parameter sync helpers; ApplianceBase plus device-type subclasses (dw, ov, ref, td, wc, wd, wh, wm) overriding attributes()/settings() per appliance category.
Diagnostics and printer utilities
custom_components/haier_hon/_vendor/pyhon/diagnose.py, custom_components/haier_hon/_vendor/pyhon/printer.py
anonymize_data, async topic loading/JSON/ZIP export, and yaml_export for appliance diagnostics; key_print, pretty_print, create_commands, and create_rules for YAML-like text rendering.
Connection handler stack
custom_components/haier_hon/_vendor/pyhon/connection/handler/*, custom_components/haier_hon/_vendor/pyhon/connection/device.py
ConnectionHandler base with aiohttp session lifecycle; HonAuthConnectionHandler (user-agent injection); HonAnonymousConnectionHandler (api-key injection, 403 logging); HonDevice metadata; HonConnectionHandler with token-refresh retry loop on 401/403 and JSON-decode error raising.
HonAuth multi-step OAuth login
custom_components/haier_hon/_vendor/pyhon/connection/auth.py
Full Haier HON authentication: OAuth authorize → Aura credential POST → HTML token extraction → Cognito ID token exchange, with authenticate()/refresh()/clear() and expiration tracking.
HonAPI cloud client and TestAPI
custom_components/haier_hon/_vendor/pyhon/connection/api.py
Async HonAPI with appliance/command/attribute/stats/AWS-token fetching, send_command with resultCode validation; TestAPI subclass loading fixture JSON from disk for offline testing.
MQTTClient: AWS IoT real-time updates
custom_components/haier_hon/_vendor/pyhon/connection/mqtt.py
MQTT5 websocket client over AWS IoT: lifecycle callbacks, _on_publish_received syncing appliance state on appliancestatus/connected/disconnected topics, appliance subscription, and reconnecting watchdog.
Hon top-level client, package init, and CLI
custom_components/haier_hon/_vendor/pyhon/hon.py, custom_components/haier_hon/_vendor/pyhon/__init__.py, custom_components/haier_hon/_vendor/pyhon/__main__.py
Hon async context-manager entry point with multi-zone appliance loading, TestAPI integration, lazy MQTT setup; __init__.py re-exporting Hon/HonAPI; CLI for keys/export/translate commands.
Integration wiring and test updates
custom_components/haier_hon/hon_client.py, custom_components/haier_hon/logging_utils.py, tests/test_enum_patch.py, tests/test_mqtt_log_level.py
hon_client.py imports switched to vendored namespace; MQTT_NOISE_LOGGERS updated to the vendored MQTT logger path; enum-patch and MQTT log-level tests updated to stub/assert vendored module paths.

Sequence Diagram(s)

sequenceDiagram
  participant HonClient as HonClient (haier_hon)
  participant Hon as Hon (vendored)
  participant HonAPI as HonAPI
  participant HonConnectionHandler
  participant HonAuth
  participant HaierCloud as Haier Cloud
  participant MQTTClient

  HonClient->>Hon: create() / async with Hon(...)
  Hon->>HonAPI: create(email, password, session)
  HonAPI->>HonConnectionHandler: create()
  HonConnectionHandler->>HonAuth: authenticate()
  HonAuth->>HaierCloud: _introduce → _login → _get_token → _api_auth
  HaierCloud-->>HonAuth: cognito_token, id_token
  HonAuth-->>HonConnectionHandler: tokens ready
  Hon->>HonAPI: load_appliances()
  HonAPI-->>Hon: appliance data list
  loop per appliance
    Hon->>HonAPI: load_commands / load_attributes / load_statistics
    HonAPI-->>Hon: HonAppliance populated
  end
  Hon->>MQTTClient: create() → subscribe appliances → start_watchdog
  MQTTClient->>HaierCloud: MQTT5 connect (AWS IoT websocket)
  HaierCloud-->>MQTTClient: publish (appliancestatus / connected)
  MQTTClient->>Hon: notify() → HonClient callback
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • telard-pixel/haier_hon#15: Updates logging_utils.py to point MQTT "noise" logger filtering at the vendored custom_components.haier_hon._vendor.pyhon.connection.mqtt namespace, which is directly wired by this PR's MQTT_NOISE_LOGGERS change and matching test update.

Poem

🐇 Hop hop, the rabbit digs a burrow deep,
No more borrowing pyhon from the heap!
Scripts rewrite imports, one namespace to rule,
_vendor/pyhon now lives in our own hutch so cool.
Cognito tokens, MQTT, commands galore—
All vendored within, no external store!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (21)
custom_components/haier_hon/_vendor/pyhon/connection/api.py-243-249 (1)

243-249: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Response body consumed twice in error path.

Line 244 calls await response.json() to get the result, then line 247 calls await response.text() in the error branch. Since the response body has already been read, the second call will return empty or fail.

This is likely the same pattern issue as in hon.py. The error logging should use the already-parsed json_data variable instead.

🔧 Proposed fix
         async with self._hon.post(url, json=data) as response:
             json_data: Dict[str, Any] = await response.json()
             if json_data.get("payload", {}).get("resultCode") == "0":
                 return True
-            _LOGGER.error(await response.text())
+            _LOGGER.error(json_data)
             _LOGGER.error("%s - Payload:\n%s", url, pformat(data))
         return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/connection/api.py` around lines 243
- 249, The response body is being consumed twice in this async POST request
handler. The first `await response.json()` call consumes the response body and
stores it in `json_data`, but then the error path attempts to read it again with
`await response.text()`, which will fail or return empty since the body has
already been consumed. In the error logging section where `_LOGGER.error(await
response.text())` is called, replace the `await response.text()` call with the
already-parsed `json_data` variable using pformat to format it consistently with
how the request data parameter is logged.
scripts/vendor_pyhon.py-67-101 (1)

67-101: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Incomplete namespacing logic can silently miss valid pyhon import forms.

Current rewrite/sanity logic only handles from pyhon... plus one f-string pattern. It does not handle import pyhon..., so the script can pass sanity checks while leaving unresolved imports in vendored code.

🔧 Proposed fix
@@
 import argparse
 import datetime
 import os
+import re
 import shutil
 import subprocess
 import sys
 import tempfile
@@
 def _rewrite_imports() -> int:
@@
-            updated = original.replace("from pyhon", "from " + NEW_PKG)
+            updated = re.sub(
+                r"(?m)^(\s*from\s+)pyhon(?=[\s.])",
+                rf"\1{NEW_PKG}",
+                original,
+            )
+            updated = re.sub(
+                r"(?m)^(\s*import\s+)pyhon(?=[\s.])",
+                rf"\1{NEW_PKG}",
+                updated,
+            )
             updated = updated.replace(
                 'f"pyhon.appliances.', 'f"' + NEW_PKG + ".appliances."
             )
@@
 def _sanity_check() -> list[str]:
@@
-                    if stripped.startswith("from pyhon") or 'f"pyhon.' in line:
+                    if re.search(r"^\s*(from|import)\s+pyhon(?=[\s.])", line) or 'f"pyhon.' in line:
                         leftovers.append(f"{os.path.relpath(path, REPO_ROOT)}:{i}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/vendor_pyhon.py` around lines 67 - 101, The _rewrite_imports()
function handles "from pyhon" imports and f-string patterns but does not handle
direct "import pyhon" statements, allowing incomplete namespace rewriting to
pass silently. Add a replacement rule in _rewrite_imports() to convert "import
pyhon" to "import " + NEW_PKG, similar to the existing "from pyhon" replacement.
Similarly, extend the sanity check in _sanity_check() to detect lines starting
with "import pyhon" as unresolved imports by adding the check to the conditional
that currently only looks for "from pyhon" and f-string patterns.
custom_components/haier_hon/_vendor/pyhon/parameter/range.py-59-63 (1)

59-63: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Step validation is numerically fragile and can reject valid values.

The modulo check in Line 61-Line 63 assumes two-decimal precision and relies on float remainder behavior. Valid step-aligned inputs can fail for increments like 0.001 or other non-2dp steps.

Proposed fix
+import math
 from typing import Dict, Any, List
@@
     `@value.setter`
     def value(self, value: str | float) -> None:
         value = str_to_float(value)
-        if self.min <= value <= self.max and not ((value - self.min) * 100) % (
-            self.step * 100
-        ):
+        step = self.step
+        if step <= 0:
+            raise ValueError(f"Step must be > 0. But was: {step}")
+
+        delta = (value - self.min) / step
+        if self.min <= value <= self.max and math.isclose(
+            delta, round(delta), abs_tol=1e-9
+        ):
             self._value = value
             self.check_trigger(value)
         else:
             allowed = f"min {self.min} max {self.max} step {self.step}"
             raise ValueError(f"Allowed: {allowed} But was: {value}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/range.py` around lines 59
- 63, The step validation in the value property setter uses a fragile
modulo-based check that multiplies by 100, which assumes 2-decimal precision and
fails for steps like 0.001 or other non-2dp increments. Replace the modulo check
in the condition (the calculation with (value - self.min) * 100) % (self.step *
100) with a numerically robust approach, such as checking if the remainder when
dividing (value - self.min) by self.step is approximately zero within a small
floating-point tolerance, or use the modulo approach with a proper epsilon-based
comparison rather than exact equality.
custom_components/haier_hon/_vendor/pyhon/parameter/program.py-27-31 (1)

27-31: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

value setter leaves internal state stale.

Line 27-Line 31 updates self._command.category but never updates self._value, while the getter returns self._value. After a set, reads can return the old program value.

Proposed fix
     `@value.setter`
     def value(self, value: str | float) -> None:
         if value in self.values:
-            self._command.category = str(value)
+            self._value = str(value)
+            self._command.category = self._value
+            self.check_trigger(self._value)
         else:
             raise ValueError(f"Allowed values: {self.values} But was: {value}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/program.py` around lines
27 - 31, The `value` setter method updates `self._command.category` with the new
value but fails to update `self._value`, causing the getter to return stale
data. Fix this by ensuring the `value` setter updates both
`self._command.category` and `self._value` to the new value when validation
passes, so that subsequent reads of the value property return the most recently
set value.
custom_components/haier_hon/_vendor/pyhon/parameter/base.py-90-93 (1)

90-93: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Shared root cause: fixedValue is tested by truthiness instead of key presence. This misroutes rules whenever fixedValue is 0/"0".

  • custom_components/haier_hon/_vendor/pyhon/parameter/base.py#L90-L93: switch to presence-based check ("fixedValue" in rule.param_data) before assigning trigger output.
  • custom_components/haier_hon/_vendor/pyhon/rules.py#L132-L135: switch to presence-based check before _apply_fixed so falsy fixed values are still applied.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/base.py` around lines 90
- 93, The `fixedValue` checks across two files use truthiness testing instead of
key presence checks, which incorrectly skips falsy values like 0 or "0". In
custom_components/haier_hon/_vendor/pyhon/parameter/base.py lines 90-93, replace
the truthiness-based assignment (the walrus operator `if fixed_value :=
rule.param_data.get("fixedValue")`) with a presence-based check using
`"fixedValue" in rule.param_data` to determine whether to assign the fixed
value. Similarly, in custom_components/haier_hon/_vendor/pyhon/rules.py lines
132-135, update the condition that guards the call to `_apply_fixed` to use a
presence-based check (`"fixedValue" in rule.param_data`) instead of truthiness
testing, ensuring falsy fixed values are still applied.
custom_components/haier_hon/_vendor/pyhon/rules.py-54-58 (1)

54-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Extra-condition parsing mutates shared state across branches.

At Lines 54–58, extra is mutated in place and reused recursively; at Lines 75–77 the same dict reference is stored on rules. This can cross-contaminate unrelated rules.

Proposed fix
                 elif isinstance(param_data, dict):
-                    if extra is None:
-                        extra = {}
-                    extra[trigger_key] = trigger_value
+                    next_extra = dict(extra or {})
+                    next_extra[trigger_key] = trigger_value
                     for extra_key, extra_data in param_data.items():
-                        self._parse_conditions(param_key, extra_key, extra_data, extra)
+                        self._parse_conditions(param_key, extra_key, extra_data, next_extra)
@@
         self._rules.setdefault(trigger_key, []).append(
-            HonRule(trigger_key, trigger_value, param_key, param_data, extras)
+            HonRule(
+                trigger_key,
+                trigger_value,
+                param_key,
+                param_data,
+                extras.copy() if extras else None,
+            )
         )

Also applies to: 75-77

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/rules.py` around lines 54 - 58, The
`extra` dictionary is being mutated in place and its reference is being reused
across recursive calls and stored on rules, causing state to be shared between
unrelated rule branches. Create a shallow copy of the `extra` dictionary before
mutating it in the conditional block at lines 54-58 (before setting
extra[trigger_key] = trigger_value) to ensure each branch modification is
isolated. This prevents subsequent mutations from affecting rules that have
already stored references to the earlier state of the dict. Apply the same
copying pattern at lines 75-77 where the extra dict is being stored on rules to
ensure the stored state is independent.
custom_components/haier_hon/_vendor/pyhon/helper.py-1-5 (1)

1-5: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

str_to_float loses precision for float inputs.

At Line 3, calling int(string) first truncates valid float input (1.7 becomes 1), which silently changes numeric values.

Proposed fix
 def str_to_float(string: str | float) -> float:
-    try:
-        return int(string)
-    except ValueError:
-        return float(str(string).replace(",", "."))
+    if isinstance(string, (int, float)):
+        return float(string)
+    return float(string.replace(",", "."))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/helper.py` around lines 1 - 5, The
str_to_float function has a precision loss issue where calling int(string) first
truncates float inputs (e.g., 1.7 becomes 1). Fix this by changing the first
attempt in the try block from int(string) to float(string), so that float inputs
are properly converted without losing their decimal components. The except
ValueError block can remain unchanged as it handles string inputs that need
comma-to-period conversion.
custom_components/haier_hon/_vendor/pyhon/attributes.py-49-50 (1)

49-50: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize parNewVal before assigning self.value.

At Line 49, data.get("parNewVal", "") can still be None. That propagates to value parsing and can raise uncaught TypeError during reads.

Proposed fix
-        self.value = data.get("parNewVal", "")
+        raw_value = data.get("parNewVal", "")
+        self.value = "" if raw_value is None else str(raw_value)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/attributes.py` around lines 49 -
50, The assignment `self.value = data.get("parNewVal", "")` does not properly
handle the case where "parNewVal" exists in the data dictionary but has a `None`
value. The default empty string only applies when the key is missing, not when
it's explicitly `None`. Normalize the value retrieved from data.get("parNewVal",
"") to ensure `None` is converted to an empty string or appropriate default
before assigning it to `self.value`.
custom_components/haier_hon/_vendor/pyhon/rules.py-99-102 (1)

99-102: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Extra-rule matching compares parameter objects, not their values.

At Line 101, str(self._command.parameters.get(key)) stringifies the parameter instance rather than comparing its current value, which can cause false mismatches.

Proposed fix
                 if not self._command.parameters.get(key):
                     return False
-                if str(self._command.parameters.get(key)) != str(value):
+                if str(self._command.parameters.get(key).value) != str(value):
                     return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/rules.py` around lines 99 - 102,
The comparison in the second if statement within the parameters validation logic
is stringifying the entire parameter object rather than its actual value.
Instead of converting the parameter object itself to a string with
str(self._command.parameters.get(key)), you need to access the actual value
property or attribute of the parameter object and convert that to a string for
comparison. This will ensure the rule matching compares the actual parameter
values rather than their object representations.
custom_components/haier_hon/_vendor/pyhon/parameter/enum.py-46-51 (1)

46-51: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enum setter validates against normalized values but compares unnormalized input.

At Line 47, raw value is checked directly against self.values (already normalized), so semantically valid values can be rejected.

Proposed fix
     `@value.setter`
     def value(self, value: str | float) -> None:
-        if value in self.values:
-            self._value = value
-            self.check_trigger(value)
+        normalized = clean_value(value)
+        if normalized in self.values:
+            self._value = normalized
+            self.check_trigger(normalized)
         else:
             raise ValueError(f"Allowed values: {self._values} But was: {value}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/enum.py` around lines 46
- 51, The `value` property setter compares the raw, unnormalized input parameter
directly against `self.values` which contains normalized values, causing
semantically valid inputs to be rejected. To fix this, normalize the input
`value` parameter before comparing it against `self.values` on line 47, ensuring
the raw input is converted to the same format as the values already stored in
`self.values`.
custom_components/haier_hon/_vendor/pyhon/command_loader.py-145-154 (1)

145-154: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix command-history lookup to actually pick the latest execution.

_get_last_command_index currently returns the first matching history entry, so _recover_last_command_states() can restore stale values instead of the most recent state.

Suggested patch
 def _get_last_command_index(self, name: str) -> Optional[int]:
     """Get index of last command execution"""
-    return next(
-        (
-            index
-            for (index, d) in enumerate(self._command_history)
-            if d.get("command", {}).get("commandName") == name
-        ),
-        None,
-    )
+    for index in range(len(self._command_history) - 1, -1, -1):
+        if self._command_history[index].get("command", {}).get("commandName") == name:
+            return index
+    return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/command_loader.py` around lines 145
- 154, The _get_last_command_index method currently returns the first matching
command history entry instead of the last one. Modify the implementation to
iterate through the command history in reverse order (from most recent to
oldest) so that it returns the index of the most recent command execution with
the given name instead of the first occurrence. This ensures that
_recover_last_command_states() restores the latest command state rather than a
stale one.
custom_components/haier_hon/_vendor/pyhon/command_loader.py-198-203 (1)

198-203: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard favourite resolution against unknown command names.

self.commands[command_name] can raise KeyError for stale/invalid favourite payloads and abort the whole command load.

Suggested patch
 def _get_favourite_info(
     self, favourite: Dict[str, Any]
 ) -> tuple[str, str, HonCommand | None]:
-    name: str = favourite.get("favouriteName", {})
+    name: str = favourite.get("favouriteName", "")
     command = favourite.get("command", {})
     command_name: str = command.get("commandName", "")
     program_name = self._clean_name(command.get("programName", ""))
-    base_command = self.commands[command_name].categories.get(program_name)
+    cmd = self.commands.get(command_name)
+    base_command = cmd.categories.get(program_name) if cmd else None
     return name, command_name, base_command
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/command_loader.py` around lines 198
- 203, The code accesses self.commands[command_name] without first verifying
that command_name exists in the dictionary, which can raise a KeyError when
favourite payloads contain stale or invalid command names. Add a guard condition
to check if command_name exists in self.commands before attempting to access it,
handling the case appropriately (such as returning early, returning None, or
skipping the favourite) to prevent the entire command load from aborting when
invalid command names are encountered.
custom_components/haier_hon/_vendor/pyhon/diagnose.py-81-92 (1)

81-92: ⚠️ Potential issue | 🟠 Major

Fix state mutation in yaml_export anonymous mode by copying appliance info.

The appliance.info property (line 170-171 in appliance.py) returns self._info directly without copying. When data["appliance"] = appliance.info is assigned in diagnose.py, it creates a reference to the internal dict. The subsequent pop() calls in anonymous mode (lines 89-91) mutate the appliance object's internal _info dict, permanently removing serialNumber and coords from the runtime state.

Solution: Replace "appliance": appliance.info with "appliance": appliance.info.copy() to isolate the diagnostic export from the live appliance object.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/diagnose.py` around lines 81 - 92,
The appliance.info property returns a direct reference to the internal _info
dictionary, and assigning it to the data dictionary in the yaml_export function
creates a reference rather than a copy. When the subsequent pop() calls execute
in anonymous mode to remove "serialNumber" and "coords", they mutate the
original appliance object's internal state. Fix this by modifying the
"appliance" key assignment to use appliance.info.copy() instead of
appliance.info directly, which creates a shallow copy and isolates the
diagnostic export data from the live appliance object.
custom_components/haier_hon/_vendor/pyhon/command_loader.py-190-193 (1)

190-193: ⚠️ Potential issue | 🟠 Major

Replace shallow copy with deepcopy to prevent favourite mutations from affecting original base commands.

The copy(base) at line 190 creates only a shallow copy, so the _parameters dict is shared between the original base command and the copied command. Subsequent mutations at lines 215, 219, and 226 modify parameters through the shared dict reference, causing changes to leak back into the original base command in self.commands[command_name].categories. This can corrupt state across multiple favourites or categories that share the same base.

Suggested fix
-from copy import copy
+from copy import deepcopy
@@
-            base_command: HonCommand = copy(base)
+            base_command: HonCommand = deepcopy(base)

Also applies to: 205-216, 219-227

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/command_loader.py` around lines 190
- 193, Replace the shallow copy with a deep copy in the base_command assignment.
Change `copy(base)` to `deepcopy(base)` at the line where base_command is
initialized. This ensures that nested objects like the _parameters dict are
fully duplicated, preventing mutations made in subsequent calls to
_update_base_command_with_data, _update_base_command_with_favourite, and
_update_program_categories from affecting the original base command stored in
self.commands. You will need to ensure deepcopy is imported from the copy
module.
custom_components/haier_hon/_vendor/pyhon/appliance.py-74-76 (1)

74-76: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

__getitem__ can throw before fallback when parameters is not loaded.

At Line 74, direct access to self.attributes["parameters"] raises KeyError when load_attributes() has not populated the structure yet, so Line 76 fallback (self.info[item]) is skipped.

Suggested fix
-        if item in self.attributes["parameters"]:
-            return self.attributes["parameters"][item].value
+        parameters = self.attributes.get("parameters", {})
+        if item in parameters:
+            return parameters[item].value
         return self.info[item]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/appliance.py` around lines 74 - 76,
The `__getitem__` method attempts direct dictionary access to
self.attributes["parameters"] at line 74, which raises KeyError if the
"parameters" key hasn't been populated by load_attributes() yet, preventing the
fallback to self.info[item] from executing. Fix this by first checking that
"parameters" exists in self.attributes before attempting to access it—modify the
condition at line 74 to check for the key's existence before checking if the
item is in that nested dictionary, so the method gracefully falls back to
self.info[item] when parameters haven't been loaded.
custom_components/haier_hon/_vendor/pyhon/appliance.py-130-133 (1)

130-133: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard empty brand values before indexing.

At Line 132, brand[0] can raise IndexError because _check_name_zone("brand") may return "".

Suggested fix
     def brand(self) -> str:
         brand = self._check_name_zone("brand")
-        return brand[0].upper() + brand[1:]
+        return brand[:1].upper() + brand[1:] if brand else ""
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/appliance.py` around lines 130 -
133, The brand property method does not guard against empty strings before
indexing. When _check_name_zone("brand") returns an empty string, the expression
brand[0] raises an IndexError. Add a guard check in the brand property to return
an appropriate default value (such as an empty string) when the result from
_check_name_zone("brand") is empty, before attempting to access brand[0] and
perform the string manipulation with upper() and slicing.
custom_components/haier_hon/_vendor/pyhon/appliance.py-46-49 (1)

46-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Initialize self._connection from real attribute payload, not the empty cache.

At Line 47, _connection is computed from self._attributes immediately after self._attributes is set to {} (Line 41), so initial connection state is effectively always “connected.” This also undermines offline normalization logic used by appliance-specific attributes() overrides.

Suggested fix
-        self._attributes: Dict[str, Any] = {}
+        self._attributes: Dict[str, Any] = {}

         self._zone: int = zone
         self._additional_data: Dict[str, Any] = {}
         self._last_update: Optional[datetime] = None
         self._default_setting = HonParameter("", {}, "")
-        self._connection = (
-            not self._attributes.get("lastConnEvent", {}).get("category", "")
-            == "DISCONNECTED"
-        )
+        last_conn_event = self._info.get("attributes", {}).get("lastConnEvent", {})
+        category = (
+            last_conn_event.get("category", "")
+            if isinstance(last_conn_event, dict)
+            else str(last_conn_event)
+        )
+        self._connection = category != "DISCONNECTED"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/appliance.py` around lines 46 - 49,
The `self._connection` initialization is occurring immediately after
`self._attributes` is set to an empty dict at line 41, which causes the
connection state to always be computed as "connected" since the empty dict
doesn't contain the "lastConnEvent" key. Move the initialization of
`self._connection` to after `self._attributes` has been populated with the
actual attribute payload from the API response, so it correctly reflects the
real connection state.
custom_components/haier_hon/_vendor/pyhon/hon.py-132-133 (1)

132-133: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

close() should also shut down realtime MQTT lifecycle.

Only closing self.api leaves MQTT state unmanaged (watchdog task/client lifecycle), which can keep background activity alive past client teardown.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/hon.py` around lines 132 - 133, The
close() method only closes the API client but does not shut down the MQTT
realtime lifecycle, leaving the watchdog task and MQTT client connection active
after teardown. In addition to calling await self.api.close(), also add a call
to shut down the realtime MQTT connection (likely through a method on the
realtime attribute or client). This ensures both the API and the background MQTT
activity are properly cleaned up when the Hon client is closed.
custom_components/haier_hon/_vendor/pyhon/hon.py-69-78 (1)

69-78: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

create() leaks initialized API resources if setup() fails.

If setup() raises after HonAPI(...).create(), __aenter__ fails and __aexit__ is never invoked, so the already-open API/session can remain unclosed.

Suggested fix
 async def create(self) -> Self:
-    self._api = await HonAPI(
-        self.email,
-        self.password,
-        session=self._session,
-        mobile_id=self._mobile_id,
-        refresh_token=self._refresh_token,
-    ).create()
-    await self.setup()
-    return self
+    self._api = await HonAPI(
+        self.email,
+        self.password,
+        session=self._session,
+        mobile_id=self._mobile_id,
+        refresh_token=self._refresh_token,
+    ).create()
+    try:
+        await self.setup()
+        return self
+    except Exception:
+        await self._api.close()
+        self._api = None
+        raise
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/hon.py` around lines 69 - 78, The
create() method initializes self._api and then calls await self.setup(), but if
setup() raises an exception after HonAPI(...).create() completes, the
already-opened API resources will leak because __aexit__ is never invoked. Wrap
the await self.setup() call in a try-except block so that if setup() fails, you
can properly clean up self._api resources (likely by calling an async cleanup
method or calling close on the API) before re-raising the exception to ensure no
resource leaks occur when the context manager initialization fails.
custom_components/haier_hon/_vendor/pyhon/connection/mqtt.py-79-106 (1)

79-106: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden publish callback against malformed payloads and unmatched topics.

json.loads(...), topic-to-appliance resolution, and nested key access are all unguarded here. A single unexpected payload/topic can raise and interrupt realtime update handling.

Suggested defensive patch
 def _on_publish_received(self, data: mqtt5.PublishReceivedData) -> None:
     if not (data and data.publish_packet and data.publish_packet.payload):
         return
-    payload = json.loads(data.publish_packet.payload.decode())
-    topic = data.publish_packet.topic
-    appliance = next(
-        a for a in self._appliances if topic in a.info["topics"]["subscribe"]
-    )
+    topic = data.publish_packet.topic
+    try:
+        payload = json.loads(data.publish_packet.payload.decode())
+    except (UnicodeDecodeError, json.JSONDecodeError) as err:
+        _LOGGER.warning("Invalid MQTT payload on topic %s: %s", topic, err)
+        return
+
+    appliance = next(
+        (
+            a
+            for a in self._appliances
+            if topic in a.info.get("topics", {}).get("subscribe", [])
+        ),
+        None,
+    )
+    if appliance is None:
+        _LOGGER.debug("Ignoring MQTT message for unknown topic %s", topic)
+        return
     if topic and "appliancestatus" in topic:
-        for parameter in payload["parameters"]:
-            appliance.attributes["parameters"][parameter["parName"]].update(
+        for parameter in payload.get("parameters", []):
+            appliance.attributes["parameters"][parameter["parName"]].update(
                 parameter
             )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/connection/mqtt.py` around lines 79
- 106, The _on_publish_received method has multiple unguarded operations that
can crash the callback: json.loads() on the payload, the next() appliance lookup
by topic, and nested dictionary key accesses like payload["parameters"] and
parameter["parName"]. Wrap the json.loads() call in a try-except block to catch
JSONDecodeError, add error handling around the next() call to catch
StopIteration when no matching appliance is found for the topic, and add
defensive checks before accessing nested keys in payload and parameter
dictionaries (using dict.get() with defaults or haskey checks as appropriate).
Log any errors encountered so issues are visible without breaking the realtime
update handling flow.
custom_components/haier_hon/_vendor/pyhon/connection/mqtt.py-131-133 (1)

131-133: ⚠️ Potential issue | 🟠 Major

Avoid blocking .result(10) on AWS SDK futures during async initialization.

The _subscribe() method at lines 131–133 performs a synchronous blocking wait on the AWS IoT SDK future. When called from _subscribe_appliances() (line 37) within the async create() context, this blocks the event loop. With multiple appliances, the 10-second timeouts accumulate and degrade Home Assistant's startup responsiveness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/connection/mqtt.py` around lines
131 - 133, The _subscribe() method uses a blocking .result(10) call on the AWS
IoT SDK future, which blocks the event loop when called from the async
_subscribe_appliances() method during create() initialization. Replace the
synchronous blocking pattern with an async-compatible approach by awaiting the
subscription result instead of calling .result(10), allowing the event loop to
continue processing other tasks rather than accumulating 10-second timeouts per
appliance.
🟡 Minor comments (4)
custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py-37-37 (1)

37-37: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Typo in error message.

The error message says "password address" but should say "password".

📝 Proposed fix
-            raise HonAuthenticationError("A password address must be specified")
+            raise HonAuthenticationError("A password must be specified")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py` at line
37, The error message in the HonAuthenticationError at line 37 contains a typo:
it says "A password address must be specified" but should say "A password must
be specified". Fix this by removing the word "address" from the error message
string in the HonAuthenticationError constructor call.
scripts/vendor_pyhon.py-49-64 (1)

49-64: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Temporary clone directories are never cleaned up.

When --source is not used, the folder created by tempfile.mkdtemp(...) is left behind after completion. Repeated runs will accumulate stale directories.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/vendor_pyhon.py` around lines 49 - 64, The temporary directory
created by tempfile.mkdtemp() in the function (around line 49) is returned to
the caller but never deleted, causing stale directories to accumulate on
repeated runs. Replace the mkdtemp() call with tempfile.TemporaryDirectory()
context manager to ensure automatic cleanup, or if the caller needs to use the
directory beyond this function's scope, ensure the caller properly cleans up the
temporary directory after use (using shutil.rmtree or a context manager at the
call site). The function returns tmp, ref, sha so the caller will need to handle
cleanup of the temporary path after all work with it is complete.
custom_components/haier_hon/logging_utils.py-25-25 (1)

25-25: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace the Cyrillic О in the comment with Latin O.

This is easy to miss and can break copy/paste or text searches for pyhOn.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/logging_utils.py` at line 25, In the comment on
line 25 of custom_components/haier_hon/logging_utils.py, replace the Cyrillic
character О in "pyhОn" with the standard Latin letter O to make it "python".
This will ensure the text can be properly searched and copied without issues.

Source: Linters/SAST tools

custom_components/haier_hon/_vendor/pyhon/hon.py-112-118 (1)

112-118: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Refactor the and/or test-data probe into explicit branches.

This condition is hard to reason about and precedence-sensitive. Splitting it into explicit steps avoids ambiguous behavior and aligns with the static-analysis warning.

Suggested rewrite
-        if (
-            self._test_data_path
-            and (
-                test_data := self._test_data_path / "hon-test-data" / "test_data"
-            ).exists()
-            or (test_data := test_data / "..").exists()
-        ):
+        test_data = self._test_data_path / "hon-test-data" / "test_data"
+        if not test_data.exists():
+            test_data = test_data.parent
+        if test_data.exists():
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/hon.py` around lines 112 - 118, The
if condition starting around line 112 mixes `and` and `or` operators in a way
that is ambiguous due to operator precedence and difficult to follow. Refactor
this compound condition into explicit sequential if-elif branches instead of
relying on operator precedence. First check if self._test_data_path exists, then
explicitly check the first test_data path (self._test_data_path /
"hon-test-data" / "test_data"), and then check the parent directory path
(test_data / "..") as a separate condition. This makes the logic clearer and
avoids precedence-related ambiguity while keeping the same functionality.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py`:
- Around line 108-119: The `await response.json()` call at line 110 consumes the
response body stream, preventing the caller from reading the body when the
response is yielded at line 111. To fix this, avoid consuming the response body
during validation. Instead, either cache the JSON parsing result before yielding
(so the caller can reuse it), or use `response.content.read()` to validate the
raw body bytes without exhausting the stream. Update the code in the exception
handler block to use one of these approaches, ensuring the response body remains
available for the caller to read in
`custom_components/haier_hon/_vendor/pyhon/connection/api.py` at line 126 where
`await response.json()` is called after yielding.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/base.py`:
- Around line 71-74: The case-insensitive trigger lookup has a bug where the
dictionary is built with lowercase keys at the initial dictionary comprehension
(triggers dictionary), but the membership check at line 72 correctly uses
str(value).lower(), yet the actual lookup at line 73 uses triggers[str(value)]
instead of triggers[str(value).lower()]. Change the dictionary access on line 73
from triggers[str(value)] to triggers[str(value).lower()] to ensure the lookup
key matches the lowercase keys that were created in the triggers dictionary
comprehension.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/enum.py`:
- Around line 17-18: The code at line 17 in the enum.py file calls strip("[]")
directly on self._default without verifying it is a string first, which will
raise an AttributeError when defaultValue is numeric. Convert self._default to a
string before calling strip(), or wrap the strip operation with type checking to
handle both string and non-string default values safely. This ensures the
normalization process doesn't crash regardless of the default value's type.

---

Major comments:
In `@custom_components/haier_hon/_vendor/pyhon/appliance.py`:
- Around line 74-76: The `__getitem__` method attempts direct dictionary access
to self.attributes["parameters"] at line 74, which raises KeyError if the
"parameters" key hasn't been populated by load_attributes() yet, preventing the
fallback to self.info[item] from executing. Fix this by first checking that
"parameters" exists in self.attributes before attempting to access it—modify the
condition at line 74 to check for the key's existence before checking if the
item is in that nested dictionary, so the method gracefully falls back to
self.info[item] when parameters haven't been loaded.
- Around line 130-133: The brand property method does not guard against empty
strings before indexing. When _check_name_zone("brand") returns an empty string,
the expression brand[0] raises an IndexError. Add a guard check in the brand
property to return an appropriate default value (such as an empty string) when
the result from _check_name_zone("brand") is empty, before attempting to access
brand[0] and perform the string manipulation with upper() and slicing.
- Around line 46-49: The `self._connection` initialization is occurring
immediately after `self._attributes` is set to an empty dict at line 41, which
causes the connection state to always be computed as "connected" since the empty
dict doesn't contain the "lastConnEvent" key. Move the initialization of
`self._connection` to after `self._attributes` has been populated with the
actual attribute payload from the API response, so it correctly reflects the
real connection state.

In `@custom_components/haier_hon/_vendor/pyhon/attributes.py`:
- Around line 49-50: The assignment `self.value = data.get("parNewVal", "")`
does not properly handle the case where "parNewVal" exists in the data
dictionary but has a `None` value. The default empty string only applies when
the key is missing, not when it's explicitly `None`. Normalize the value
retrieved from data.get("parNewVal", "") to ensure `None` is converted to an
empty string or appropriate default before assigning it to `self.value`.

In `@custom_components/haier_hon/_vendor/pyhon/command_loader.py`:
- Around line 145-154: The _get_last_command_index method currently returns the
first matching command history entry instead of the last one. Modify the
implementation to iterate through the command history in reverse order (from
most recent to oldest) so that it returns the index of the most recent command
execution with the given name instead of the first occurrence. This ensures that
_recover_last_command_states() restores the latest command state rather than a
stale one.
- Around line 198-203: The code accesses self.commands[command_name] without
first verifying that command_name exists in the dictionary, which can raise a
KeyError when favourite payloads contain stale or invalid command names. Add a
guard condition to check if command_name exists in self.commands before
attempting to access it, handling the case appropriately (such as returning
early, returning None, or skipping the favourite) to prevent the entire command
load from aborting when invalid command names are encountered.
- Around line 190-193: Replace the shallow copy with a deep copy in the
base_command assignment. Change `copy(base)` to `deepcopy(base)` at the line
where base_command is initialized. This ensures that nested objects like the
_parameters dict are fully duplicated, preventing mutations made in subsequent
calls to _update_base_command_with_data, _update_base_command_with_favourite,
and _update_program_categories from affecting the original base command stored
in self.commands. You will need to ensure deepcopy is imported from the copy
module.

In `@custom_components/haier_hon/_vendor/pyhon/connection/api.py`:
- Around line 243-249: The response body is being consumed twice in this async
POST request handler. The first `await response.json()` call consumes the
response body and stores it in `json_data`, but then the error path attempts to
read it again with `await response.text()`, which will fail or return empty
since the body has already been consumed. In the error logging section where
`_LOGGER.error(await response.text())` is called, replace the `await
response.text()` call with the already-parsed `json_data` variable using pformat
to format it consistently with how the request data parameter is logged.

In `@custom_components/haier_hon/_vendor/pyhon/connection/mqtt.py`:
- Around line 79-106: The _on_publish_received method has multiple unguarded
operations that can crash the callback: json.loads() on the payload, the next()
appliance lookup by topic, and nested dictionary key accesses like
payload["parameters"] and parameter["parName"]. Wrap the json.loads() call in a
try-except block to catch JSONDecodeError, add error handling around the next()
call to catch StopIteration when no matching appliance is found for the topic,
and add defensive checks before accessing nested keys in payload and parameter
dictionaries (using dict.get() with defaults or haskey checks as appropriate).
Log any errors encountered so issues are visible without breaking the realtime
update handling flow.
- Around line 131-133: The _subscribe() method uses a blocking .result(10) call
on the AWS IoT SDK future, which blocks the event loop when called from the
async _subscribe_appliances() method during create() initialization. Replace the
synchronous blocking pattern with an async-compatible approach by awaiting the
subscription result instead of calling .result(10), allowing the event loop to
continue processing other tasks rather than accumulating 10-second timeouts per
appliance.

In `@custom_components/haier_hon/_vendor/pyhon/diagnose.py`:
- Around line 81-92: The appliance.info property returns a direct reference to
the internal _info dictionary, and assigning it to the data dictionary in the
yaml_export function creates a reference rather than a copy. When the subsequent
pop() calls execute in anonymous mode to remove "serialNumber" and "coords",
they mutate the original appliance object's internal state. Fix this by
modifying the "appliance" key assignment to use appliance.info.copy() instead of
appliance.info directly, which creates a shallow copy and isolates the
diagnostic export data from the live appliance object.

In `@custom_components/haier_hon/_vendor/pyhon/helper.py`:
- Around line 1-5: The str_to_float function has a precision loss issue where
calling int(string) first truncates float inputs (e.g., 1.7 becomes 1). Fix this
by changing the first attempt in the try block from int(string) to
float(string), so that float inputs are properly converted without losing their
decimal components. The except ValueError block can remain unchanged as it
handles string inputs that need comma-to-period conversion.

In `@custom_components/haier_hon/_vendor/pyhon/hon.py`:
- Around line 132-133: The close() method only closes the API client but does
not shut down the MQTT realtime lifecycle, leaving the watchdog task and MQTT
client connection active after teardown. In addition to calling await
self.api.close(), also add a call to shut down the realtime MQTT connection
(likely through a method on the realtime attribute or client). This ensures both
the API and the background MQTT activity are properly cleaned up when the Hon
client is closed.
- Around line 69-78: The create() method initializes self._api and then calls
await self.setup(), but if setup() raises an exception after
HonAPI(...).create() completes, the already-opened API resources will leak
because __aexit__ is never invoked. Wrap the await self.setup() call in a
try-except block so that if setup() fails, you can properly clean up self._api
resources (likely by calling an async cleanup method or calling close on the
API) before re-raising the exception to ensure no resource leaks occur when the
context manager initialization fails.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/base.py`:
- Around line 90-93: The `fixedValue` checks across two files use truthiness
testing instead of key presence checks, which incorrectly skips falsy values
like 0 or "0". In custom_components/haier_hon/_vendor/pyhon/parameter/base.py
lines 90-93, replace the truthiness-based assignment (the walrus operator `if
fixed_value := rule.param_data.get("fixedValue")`) with a presence-based check
using `"fixedValue" in rule.param_data` to determine whether to assign the fixed
value. Similarly, in custom_components/haier_hon/_vendor/pyhon/rules.py lines
132-135, update the condition that guards the call to `_apply_fixed` to use a
presence-based check (`"fixedValue" in rule.param_data`) instead of truthiness
testing, ensuring falsy fixed values are still applied.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/enum.py`:
- Around line 46-51: The `value` property setter compares the raw, unnormalized
input parameter directly against `self.values` which contains normalized values,
causing semantically valid inputs to be rejected. To fix this, normalize the
input `value` parameter before comparing it against `self.values` on line 47,
ensuring the raw input is converted to the same format as the values already
stored in `self.values`.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/program.py`:
- Around line 27-31: The `value` setter method updates `self._command.category`
with the new value but fails to update `self._value`, causing the getter to
return stale data. Fix this by ensuring the `value` setter updates both
`self._command.category` and `self._value` to the new value when validation
passes, so that subsequent reads of the value property return the most recently
set value.

In `@custom_components/haier_hon/_vendor/pyhon/parameter/range.py`:
- Around line 59-63: The step validation in the value property setter uses a
fragile modulo-based check that multiplies by 100, which assumes 2-decimal
precision and fails for steps like 0.001 or other non-2dp increments. Replace
the modulo check in the condition (the calculation with (value - self.min) *
100) % (self.step * 100) with a numerically robust approach, such as checking if
the remainder when dividing (value - self.min) by self.step is approximately
zero within a small floating-point tolerance, or use the modulo approach with a
proper epsilon-based comparison rather than exact equality.

In `@custom_components/haier_hon/_vendor/pyhon/rules.py`:
- Around line 54-58: The `extra` dictionary is being mutated in place and its
reference is being reused across recursive calls and stored on rules, causing
state to be shared between unrelated rule branches. Create a shallow copy of the
`extra` dictionary before mutating it in the conditional block at lines 54-58
(before setting extra[trigger_key] = trigger_value) to ensure each branch
modification is isolated. This prevents subsequent mutations from affecting
rules that have already stored references to the earlier state of the dict.
Apply the same copying pattern at lines 75-77 where the extra dict is being
stored on rules to ensure the stored state is independent.
- Around line 99-102: The comparison in the second if statement within the
parameters validation logic is stringifying the entire parameter object rather
than its actual value. Instead of converting the parameter object itself to a
string with str(self._command.parameters.get(key)), you need to access the
actual value property or attribute of the parameter object and convert that to a
string for comparison. This will ensure the rule matching compares the actual
parameter values rather than their object representations.

In `@scripts/vendor_pyhon.py`:
- Around line 67-101: The _rewrite_imports() function handles "from pyhon"
imports and f-string patterns but does not handle direct "import pyhon"
statements, allowing incomplete namespace rewriting to pass silently. Add a
replacement rule in _rewrite_imports() to convert "import pyhon" to "import " +
NEW_PKG, similar to the existing "from pyhon" replacement. Similarly, extend the
sanity check in _sanity_check() to detect lines starting with "import pyhon" as
unresolved imports by adding the check to the conditional that currently only
looks for "from pyhon" and f-string patterns.

---

Minor comments:
In `@custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py`:
- Line 37: The error message in the HonAuthenticationError at line 37 contains a
typo: it says "A password address must be specified" but should say "A password
must be specified". Fix this by removing the word "address" from the error
message string in the HonAuthenticationError constructor call.

In `@custom_components/haier_hon/_vendor/pyhon/hon.py`:
- Around line 112-118: The if condition starting around line 112 mixes `and` and
`or` operators in a way that is ambiguous due to operator precedence and
difficult to follow. Refactor this compound condition into explicit sequential
if-elif branches instead of relying on operator precedence. First check if
self._test_data_path exists, then explicitly check the first test_data path
(self._test_data_path / "hon-test-data" / "test_data"), and then check the
parent directory path (test_data / "..") as a separate condition. This makes the
logic clearer and avoids precedence-related ambiguity while keeping the same
functionality.

In `@custom_components/haier_hon/logging_utils.py`:
- Line 25: In the comment on line 25 of
custom_components/haier_hon/logging_utils.py, replace the Cyrillic character О
in "pyhОn" with the standard Latin letter O to make it "python". This will
ensure the text can be properly searched and copied without issues.

In `@scripts/vendor_pyhon.py`:
- Around line 49-64: The temporary directory created by tempfile.mkdtemp() in
the function (around line 49) is returned to the caller but never deleted,
causing stale directories to accumulate on repeated runs. Replace the mkdtemp()
call with tempfile.TemporaryDirectory() context manager to ensure automatic
cleanup, or if the caller needs to use the directory beyond this function's
scope, ensure the caller properly cleans up the temporary directory after use
(using shutil.rmtree or a context manager at the call site). The function
returns tmp, ref, sha so the caller will need to handle cleanup of the temporary
path after all work with it is complete.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 16300519-20f5-4970-998c-252bdf223d82

📥 Commits

Reviewing files that changed from the base of the PR and between d400a24 and 396a196.

📒 Files selected for processing (50)
  • custom_components/haier_hon/_vendor/VENDOR.md
  • custom_components/haier_hon/_vendor/__init__.py
  • custom_components/haier_hon/_vendor/pyhon/LICENSE
  • custom_components/haier_hon/_vendor/pyhon/__init__.py
  • custom_components/haier_hon/_vendor/pyhon/__main__.py
  • custom_components/haier_hon/_vendor/pyhon/appliance.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/__init__.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/base.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/dw.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/ov.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/ref.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/td.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/wc.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/wd.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/wh.py
  • custom_components/haier_hon/_vendor/pyhon/appliances/wm.py
  • custom_components/haier_hon/_vendor/pyhon/attributes.py
  • custom_components/haier_hon/_vendor/pyhon/command_loader.py
  • custom_components/haier_hon/_vendor/pyhon/commands.py
  • custom_components/haier_hon/_vendor/pyhon/connection/__init__.py
  • custom_components/haier_hon/_vendor/pyhon/connection/api.py
  • custom_components/haier_hon/_vendor/pyhon/connection/auth.py
  • custom_components/haier_hon/_vendor/pyhon/connection/device.py
  • custom_components/haier_hon/_vendor/pyhon/connection/handler/__init__.py
  • custom_components/haier_hon/_vendor/pyhon/connection/handler/anonym.py
  • custom_components/haier_hon/_vendor/pyhon/connection/handler/auth.py
  • custom_components/haier_hon/_vendor/pyhon/connection/handler/base.py
  • custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py
  • custom_components/haier_hon/_vendor/pyhon/connection/mqtt.py
  • custom_components/haier_hon/_vendor/pyhon/const.py
  • custom_components/haier_hon/_vendor/pyhon/diagnose.py
  • custom_components/haier_hon/_vendor/pyhon/exceptions.py
  • custom_components/haier_hon/_vendor/pyhon/helper.py
  • custom_components/haier_hon/_vendor/pyhon/hon.py
  • custom_components/haier_hon/_vendor/pyhon/parameter/__init__.py
  • custom_components/haier_hon/_vendor/pyhon/parameter/base.py
  • custom_components/haier_hon/_vendor/pyhon/parameter/enum.py
  • custom_components/haier_hon/_vendor/pyhon/parameter/fixed.py
  • custom_components/haier_hon/_vendor/pyhon/parameter/program.py
  • custom_components/haier_hon/_vendor/pyhon/parameter/range.py
  • custom_components/haier_hon/_vendor/pyhon/printer.py
  • custom_components/haier_hon/_vendor/pyhon/py.typed
  • custom_components/haier_hon/_vendor/pyhon/rules.py
  • custom_components/haier_hon/_vendor/pyhon/typedefs.py
  • custom_components/haier_hon/hon_client.py
  • custom_components/haier_hon/logging_utils.py
  • custom_components/haier_hon/manifest.json
  • scripts/vendor_pyhon.py
  • tests/test_enum_patch.py
  • tests/test_mqtt_log_level.py

Comment on lines +108 to +119
else:
try:
await response.json()
yield response
except json.JSONDecodeError as exc:
_LOGGER.warning(
"%s - JsonDecodeError %s - %s",
response.request_info.url,
response.status,
await response.text(),
)
raise HonAuthenticationError("Decode Error") from exc

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how callers use the response after _intercept yields it
rg -n -A5 'async with self\._hon\.(get|post)' --type=py

Repository: telard-pixel/haier_hon

Length of output: 7522


🏁 Script executed:

# First, let's look at the full _intercept method
sed -n '100,130p' custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py

Repository: telard-pixel/haier_hon

Length of output: 870


🏁 Script executed:

# Also check how _hon.get and _hon.post are implemented
grep -n "async def get\|async def post" custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py

Repository: telard-pixel/haier_hon

Length of output: 48


🏁 Script executed:

# Check if _intercept is used as a middleware/context manager wrapper
grep -n "_intercept\|HonClient" custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py | head -20

Repository: telard-pixel/haier_hon

Length of output: 189


🏁 Script executed:

# Look at full _intercept method implementation
sed -n '71,130p' custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py

Repository: telard-pixel/haier_hon

Length of output: 2163


🏁 Script executed:

# Check the callers at lines 82 and 96 - see what they do with response
sed -n '75,110p' custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py

Repository: telard-pixel/haier_hon

Length of output: 1558


🏁 Script executed:

# See how get() and post() use _intercept
sed -n '60,98p' custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py

Repository: telard-pixel/haier_hon

Length of output: 1829


Response body consumed before yielding, preventing caller from reading it.

Line 110 calls await response.json() to validate the response is valid JSON, which consumes the underlying aiohttp response body stream. When _intercept yields the response to the caller, any subsequent attempt to read the body (e.g., response.json() or response.text()) will fail because the stream has already been exhausted.

This is evident in the codebase at custom_components/haier_hon/_vendor/pyhon/connection/api.py:126, where the caller attempts a second await response.json() call on the same response object after it has already been consumed by the validation at line 110.

To fix: either cache the result from the validation call and skip the body read, or read the raw body directly with response.content.read() if validation is required before yielding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@custom_components/haier_hon/_vendor/pyhon/connection/handler/hon.py` around
lines 108 - 119, The `await response.json()` call at line 110 consumes the
response body stream, preventing the caller from reading the body when the
response is yielded at line 111. To fix this, avoid consuming the response body
during validation. Instead, either cache the JSON parsing result before yielding
(so the caller can reuse it), or use `response.content.read()` to validate the
raw body bytes without exhausting the stream. Update the code in the exception
handler block to use one of these approaches, ensuring the response body remains
available for the caller to read in
`custom_components/haier_hon/_vendor/pyhon/connection/api.py` at line 126 where
`await response.json()` is called after yielding.

Comment thread custom_components/haier_hon/_vendor/pyhon/parameter/base.py
Comment thread custom_components/haier_hon/_vendor/pyhon/parameter/enum.py Outdated
Pulls two latent upstream bug fixes into the vendored pyhon: case-insensitive
trigger lookup KeyError (base.py) and numeric-defaultValue crash (enum.py).
@telard-pixel
telard-pixel merged commit 678d0a1 into main Jun 16, 2026
7 of 8 checks passed
tis24dev added a commit that referenced this pull request Jun 23, 2026
Audit finding #16. async_turn_on delegated to async_set_hvac_mode(COOL), so
powering the AC on from the generic on/off toggle always switched it to COOL,
discarding the last used mode (against the HA TURN_ON convention of resuming the
previous operating state).

Send only onOffStatus=1: the hOn device keeps its stored machMode, so it resumes
the last mode. async_turn_off is unchanged (still sends onOffStatus=0).

Tests: turn_on sends only onOffStatus and leaves a present machMode untouched.
tis24dev added a commit that referenced this pull request Jun 23, 2026
Adversarial-refuter follow-up on the climate-AC fixes (#5/#9/#16):
- async_turn_on failure path now asserts it raises command_error (a swallow
  mutant previously survived: a failed send would be silently ignored).
- async_turn_on with client None raises appliance_or_client_unavailable.
- _derive_hvac_modes skips machMode enum codes not in AC_MODE_MAP (real devices
  may report unmapped codes) instead of crashing the entity.
tis24dev added a commit that referenced this pull request Jun 23, 2026
Audit finding #16. async_turn_on delegated to async_set_hvac_mode(COOL), so
powering the AC on from the generic on/off toggle always switched it to COOL,
discarding the last used mode (against the HA TURN_ON convention of resuming the
previous operating state).

Send only onOffStatus=1: the hOn device keeps its stored machMode, so it resumes
the last mode. async_turn_off is unchanged (still sends onOffStatus=0).

Tests: turn_on sends only onOffStatus and leaves a present machMode untouched.
tis24dev added a commit that referenced this pull request Jun 23, 2026
Adversarial-refuter follow-up on the climate-AC fixes (#5/#9/#16):
- async_turn_on failure path now asserts it raises command_error (a swallow
  mutant previously survived: a failed send would be silently ignored).
- async_turn_on with client None raises appliance_or_client_unavailable.
- _derive_hvac_modes skips machMode enum codes not in AC_MODE_MAP (real devices
  may report unmapped codes) instead of crashing the entity.
tis24dev added a commit that referenced this pull request Jun 23, 2026
Audit finding #16. async_turn_on delegated to async_set_hvac_mode(COOL), so
powering the AC on from the generic on/off toggle always switched it to COOL,
discarding the last used mode (against the HA TURN_ON convention of resuming the
previous operating state).

Send only onOffStatus=1: the hOn device keeps its stored machMode, so it resumes
the last mode. async_turn_off is unchanged (still sends onOffStatus=0).

Tests: turn_on sends only onOffStatus and leaves a present machMode untouched.
tis24dev added a commit that referenced this pull request Jun 23, 2026
Adversarial-refuter follow-up on the climate-AC fixes (#5/#9/#16):
- async_turn_on failure path now asserts it raises command_error (a swallow
  mutant previously survived: a failed send would be silently ignored).
- async_turn_on with client None raises appliance_or_client_unavailable.
- _derive_hvac_modes skips machMode enum codes not in AC_MODE_MAP (real devices
  may report unmapped codes) instead of crashing the entity.
@coderabbitai coderabbitai Bot mentioned this pull request Aug 2, 2026
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