From 96de2fb78e93cd00ecb03917b2e81b634c65ecf3 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:11:57 +0100 Subject: [PATCH] fix(expose,yaml): gate UI WHO 4/18 injection by firmware & fix MappingProxyType MAC mismatch (#832) - Fix _log_mac_mismatch in legacy_yaml.py and _extract_gateway_info in websocket.py checking isinstance(..., dict) instead of Mapping, which ignored real Home Assistant ConfigEntry.data (MappingProxyType) and logged false-positive myhome.yaml MAC warnings on secondary gateways (#832). - Gate WHO 4 and WHO 18 expose options and validation in ExposeSubentryFlowHandler by effective gateway profile (including shared-bus delegation), and guard WHO 18 Dimension 1200 queries/writes when WHO 18 injection is unsupported. - Allow zone master probe addresses 1..99 in WHO 4 expose validation while blocking 3-digit probe addresses >= 100 on bt_termo #0-only gateways (F461/F460/MHS1 translate 1..99 to SCS D1 03 02 F0 00 and NACK >= 100), and defer climate zone discovery on exposed WHO 4 Dimension 0 frames until a real zone frame arrives. - Document 8-gateway Firmware Oracle findings for WHO 1/2/4/13/18 expose compatibility in docs/configuration/expose.md. --- custom_components/myhome/climate.py | 20 ++ .../myhome/config_flow_expose.py | 51 +++- custom_components/myhome/expose.py | 111 +++++--- custom_components/myhome/legacy_yaml.py | 13 +- custom_components/myhome/strings.json | 4 +- custom_components/myhome/translations/en.json | 4 +- custom_components/myhome/translations/fr.json | 4 +- custom_components/myhome/translations/it.json | 4 +- custom_components/myhome/translations/nl.json | 4 +- custom_components/myhome/validate.py | 17 +- custom_components/myhome/websocket.py | 3 +- docs/configuration/expose.md | 29 ++- tests/test_expose.py | 240 +++++++++++++++--- tests/test_legacy_yaml.py | 76 ++++++ tests/test_websocket.py | 32 +++ 15 files changed, 508 insertions(+), 104 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 63cd84583..68f102df3 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -78,6 +78,7 @@ default_known_keys, is_broadcast_where, ) +from .expose import is_exposed_on_bus from .gateway import _ZONE_WHERE, SILENT_ZONE_GRACE, MyHOMEGatewayHandler from .myhome_device import MyHOMEEntity from .poll_health import PollHealth @@ -322,6 +323,25 @@ def accept(ctx: DeviceContext) -> bool: ctx.address.key, ) return False + if ( + not ctx.cfg + and ctx.source == "bus" + and getattr(ctx.message, "dimension", None) == 0 + ): + full_where = ( + f"{ctx.address.where}#4#{ctx.address.interface}" + if ctx.address.interface + else ctx.address.where + ) + if is_exposed_on_bus(hass, config_entry, 4, full_where): + zone_pair = thermo_zone_pair(ctx.address) + if zone_pair is not None: + pending_actions.setdefault(zone_pair, {})["dim0"] = ctx.message + LOGGER.debug( + "Deferring climate zone %s creation on exposed WHO=4 virtual probe Dimension 0 frame", + ctx.address.key, + ) + return False if ( ctx.source == "bus" and is_thermo_switch_actuator_zone(ctx.address) diff --git a/custom_components/myhome/config_flow_expose.py b/custom_components/myhome/config_flow_expose.py index ec88efee0..6eadaa37c 100644 --- a/custom_components/myhome/config_flow_expose.py +++ b/custom_components/myhome/config_flow_expose.py @@ -25,12 +25,22 @@ CONF_WHO, ) from .data import get_runtime_data +from .expose import effective_expose_profile, injection_block_reason from .typing_compat import flow_schema class ExposeSubentryFlowHandler(ConfigSubentryFlow): """Handle ConfigSubentryFlow for exposing Home Assistant entities to the SCS bus.""" + def _gateway_profile_for_who(self, who: int) -> typing.Any: + """Return the parent gateway's resolved profile for ``who``, or None if unavailable.""" + entry = self._get_entry() + runtime = get_runtime_data(entry) + gw = getattr(runtime, "gateway", None) if runtime is not None else None + if gw is not None: + return effective_expose_profile(gw, who) + return None + def _build_schema(self, defaults: dict[str, typing.Any]) -> typing.Any: """Build form schema for adding or reconfiguring an expose subentry.""" who_default = str(defaults.get(CONF_WHO, "1")) @@ -65,16 +75,27 @@ def _opt_float(key: str) -> float | None: except vol.Invalid: step_default = 10 + all_who_options = ( + (1, "WHO 1 — Lighting / Switch / Virtual Actuator"), + (2, "WHO 2 — Automation / Cover"), + (4, "WHO 4 — Temperature Probe (Dimension 0)"), + (13, "WHO 13 — Bus Clock & DST Sync"), + (18, "WHO 18 — Energy / Power Meter"), + ) + who_options = [ + selector.SelectOptionDict(value=str(code), label=label) + for code, label in all_who_options + if str(code) == who_default + or ( + (prof := self._gateway_profile_for_who(code)) is None + or injection_block_reason(prof, code) is None + ) + ] + schema_dict: dict[typing.Any, typing.Any] = { Required(CONF_WHO, default=who_default): selector.SelectSelector( selector.SelectSelectorConfig( - options=[ - selector.SelectOptionDict(value="1", label="WHO 1 — Lighting / Switch / Virtual Actuator"), - selector.SelectOptionDict(value="2", label="WHO 2 — Automation / Cover"), - selector.SelectOptionDict(value="4", label="WHO 4 — Temperature Probe (Dimension 0)"), - selector.SelectOptionDict(value="13", label="WHO 13 — Bus Clock & DST Sync"), - selector.SelectOptionDict(value="18", label="WHO 18 — Energy / Power Meter"), - ], + options=who_options, mode=selector.SelectSelectorMode.DROPDOWN, ) ), @@ -174,6 +195,11 @@ def _validate_and_clean( errors[CONF_WHO] = "invalid_who" return {}, errors, "" + profile = self._gateway_profile_for_who(who) + if profile is not None and injection_block_reason(profile, who) is not None: + errors[CONF_WHO] = "who_injection_unsupported" + return {}, errors, "" + raw_where = user_input.get(CONF_WHERE) where = ( "" @@ -248,12 +274,20 @@ def _validate_and_clean( errors["base"] = "invalid_expose" return {}, errors, "" + norm_where = str(cleaned.get(CONF_WHERE) or "") + if ( + profile is not None + and injection_block_reason(profile, who, norm_where) is not None + ): + errors[CONF_WHERE] = "who4_probe_where_unsupported" + return {}, errors, "" + # Check for duplicate binding on the parent entry entry = self._get_entry() candidate_dim = cleaned.get(CONF_DIMENSION) if candidate_dim is None: candidate_dim = 0 if who == 4 else (22 if who == 13 else None) - candidate_key = (int(cleaned[CONF_WHO]), str(cleaned.get(CONF_WHERE) or ""), candidate_dim) + candidate_key = (int(cleaned[CONF_WHO]), norm_where, candidate_dim) runtime = get_runtime_data(entry) existing_bindings = ( @@ -268,7 +302,6 @@ def _validate_and_clean( errors["base"] = "invalid_expose" return {}, errors, "" - norm_where = str(cleaned.get(CONF_WHERE) or "") if who == 13: title = f"Bus Clock Sync (WHO 13, dim {dim if dim is not None else 22})" else: diff --git a/custom_components/myhome/expose.py b/custom_components/myhome/expose.py index b3a033d02..0c0b59fa2 100644 --- a/custom_components/myhome/expose.py +++ b/custom_components/myhome/expose.py @@ -99,7 +99,7 @@ SUBENTRY_TYPE_EXPOSE, area_of_where, ) -from .where_grammar import expose_message_where, normalize_expose_where +from .where_grammar import expose_message_where, is_probe, normalize_expose_where if TYPE_CHECKING: from .gateway import MyHOMEGatewayHandler @@ -124,6 +124,63 @@ } +def effective_expose_profile(gateway: Any, who: int) -> Any: + """Return the active GatewayProfile for ``who``, accounting for shared-bus delegation.""" + target_gw: Any = gateway + delegated_away = getattr(target_gw, "delegated_away_whos", None) + if isinstance(delegated_away, (set, frozenset, list, tuple)) and who in delegated_away: + get_sec = getattr(target_gw, "_get_secondary_for_who", None) + sec = get_sec(who) if callable(get_sec) else None + if sec is not None and getattr(sec, "is_connected", False) is True: + target_gw = sec + profile = getattr(target_gw, "profile", None) + if profile is None: + return None + if isinstance(getattr(profile, "model_name", None), str) or any( + isinstance(getattr(profile, attr, None), bool) + for attr in ( + "supports_dimension_response_injection", + "supports_who4_command_injection", + "supports_who18_response_injection", + ) + ): + return profile + return None + + +def injection_block_reason( + profile: Any, who: int, where: str | None = None +) -> str | None: + """Return why ``profile`` cannot inject expose frames for ``who``/``where``, or None when it can. + + Only WHO 4 and WHO 18 are gated. An unset (None) OWNd flag means the gateway + family is unverified, and is treated as unsupported. On ``bt_termo`` gateways + where ``supports_dimension_response_injection`` is not True, ``*#4*W*#0*TTTT##`` + only translates master-probe addresses ``1..99`` onto the SCS bus and rejects + 3-digit probe addresses ``>= 100``. + """ + gate = _INJECTION_FLAGS.get(who) + if gate is None: + return None + flag_name, frames = gate + if profile is None: + return f"the gateway profile is not known yet, so {frames} are unverified" + if who == 4 and getattr(profile, "supports_dimension_response_injection", None) is True: + return None + flag = getattr(profile, flag_name, None) + model = getattr(profile, "model_name", None) or type(profile).__name__ + if flag is True: + if who == 4 and where and is_probe(where): + return ( + f"gateway {model} only translates WHO 4 #0 temperature writes on " + f"zone master probe addresses 1..99 (3-digit WHERE={where} is rejected by firmware)" + ) + return None + if flag is False: + return f"gateway {model} does not accept {frames} ({flag_name}=False)" + return f"gateway {model} is unverified for {frames} ({flag_name} is unset in OWNd)" + + @dataclass class _EchoGuardEntry: """Active loopback/translation echo guard entry for an outgoing frame.""" @@ -1042,43 +1099,19 @@ async def _async_dispatch_frames( def _effective_profile(self, who: int) -> Any: """Return the active GatewayProfile for ``who``, accounting for shared-bus delegation.""" - target_gw: Any = self.gateway - delegated_away = getattr(target_gw, "delegated_away_whos", None) - if isinstance(delegated_away, (set, frozenset, list, tuple)) and who in delegated_away: - get_sec = getattr(target_gw, "_get_secondary_for_who", None) - sec = get_sec(who) if callable(get_sec) else None - if sec is not None and getattr(sec, "is_connected", False) is True: - target_gw = sec - return getattr(target_gw, "profile", None) - - def _injection_block_reason(self, who: int) -> str | None: - """Return why the gateway carrying ``who`` cannot take expose frames, or None when it can. - - Only WHO 4 and WHO 18 are gated. An unset (None) OWNd flag means the gateway - family is unverified, and is treated as unsupported. - """ - gate = _INJECTION_FLAGS.get(who) - if gate is None: - return None - flag_name, frames = gate - profile = self._effective_profile(who) - if profile is None: - return f"the gateway profile is not known yet, so {frames} are unverified" - if who == 4 and getattr(profile, "supports_dimension_response_injection", None) is True: - return None - flag = getattr(profile, flag_name, None) - if flag is True: - return None - model = getattr(profile, "model_name", None) or type(profile).__name__ - if flag is False: - return f"gateway {model} does not accept {frames} ({flag_name}=False)" - return f"gateway {model} is unverified for {frames} ({flag_name} is unset in OWNd)" + return effective_expose_profile(self.gateway, who) + + def _injection_block_reason( + self, who: int, where: str | None = None + ) -> str | None: + """Return why the gateway carrying ``who`` cannot take expose frames, or None when it can.""" + return injection_block_reason(self._effective_profile(who), who, where) @callback def _warn_blocked_bindings(self) -> None: """Warn once per binding and reason when its gateway cannot take its frames.""" for binding in self.bindings: - reason = self._injection_block_reason(binding.who) + reason = self._injection_block_reason(binding.who, binding.where) if reason is None or (binding.key, reason) in self._warned_blocked: continue self._warned_blocked.add((binding.key, reason)) @@ -1299,7 +1332,7 @@ def _build_frames( return frames, who2_val_key if binding.who == 4: - if self._injection_block_reason(4) is not None: + if self._injection_block_reason(4, binding.where) is not None: return [], None supports_dim_resp = ( getattr( @@ -1730,7 +1763,13 @@ async def _async_handle_bus_message( is_reply=True, query_dimension=dim, ) - elif who == 18 and is_dim_req and dim == 1200 and binding.dimension == 113: + elif ( + who == 18 + and is_dim_req + and dim == 1200 + and binding.dimension == 113 + and self._injection_block_reason(18) is None + ): now_m = time.monotonic() remaining = ( max(1, min(255, round((binding.stream_expires_at - now_m) / 60.0))) @@ -1749,7 +1788,7 @@ async def _async_handle_bus_message( # ── 3. WHO=18 Dimension 1200 Streaming Handshake ────────────────── if who == 18 and is_dim_write and dim == 1200: - if not binding.respond_to_read: + if not binding.respond_to_read or self._injection_block_reason(18) is not None: continue if binding.dimension == 113: duration = getattr(message, "stream_duration", None) diff --git a/custom_components/myhome/legacy_yaml.py b/custom_components/myhome/legacy_yaml.py index 71ac2c0c3..50f9c1f35 100644 --- a/custom_components/myhome/legacy_yaml.py +++ b/custom_components/myhome/legacy_yaml.py @@ -3,7 +3,7 @@ import copy import os -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from typing import Any, cast from homeassistant.config_entries import ConfigEntry @@ -257,9 +257,16 @@ def _log_mac_mismatch(hass: HomeAssistant, entry: ConfigEntry, keys: list[Any]) # Entries without a MAC are skipped rather than compared against "None". other_macs: list[str] = [] for other in hass.config_entries.async_entries(DOMAIN): - if other is entry or not isinstance(getattr(other, "data", None), dict): + if other is entry or getattr(other, "entry_id", None) == entry.entry_id: continue - other_mac = other.data.get(CONF_MAC) + other_data = getattr(other, "data", None) + if not isinstance(other_data, Mapping): + continue + other_mac = other_data.get(CONF_MAC) + if other_mac is None: + uid = getattr(other, "unique_id", None) + if isinstance(uid, str) and uid: + other_mac = uid if other_mac is not None: other_macs.append(str(other_mac)) orphans = [k for k in keys if not any(_same_mac(k, mac) for mac in other_macs)] diff --git a/custom_components/myhome/strings.json b/custom_components/myhome/strings.json index 32816d6e8..4708f87a9 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -153,7 +153,9 @@ "where_required": "A WHERE address is required for this WHO subsystem.", "entity_required": "A source Home Assistant entity is required for this WHO subsystem.", "invalid_dimension": "Invalid dimension for the selected WHO subsystem.", - "invalid_expose": "Invalid expose configuration or duplicate WHO/WHERE/dimension binding." + "invalid_expose": "Invalid expose configuration or duplicate WHO/WHERE/dimension binding.", + "who_injection_unsupported": "This gateway firmware does not support injecting frames for the selected WHO subsystem onto the SCS bus (supported on all gateways: WHO 1, 2, 13).", + "who4_probe_where_unsupported": "This gateway only translates WHO 4 #0 temperature writes on unused zone master addresses 1..99 (e.g. 50); 3-digit probe addresses (100..900) are rejected by firmware." }, "abort": { "reconfigure_successful": "Exposed entity binding updated successfully." diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index 32816d6e8..4708f87a9 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -153,7 +153,9 @@ "where_required": "A WHERE address is required for this WHO subsystem.", "entity_required": "A source Home Assistant entity is required for this WHO subsystem.", "invalid_dimension": "Invalid dimension for the selected WHO subsystem.", - "invalid_expose": "Invalid expose configuration or duplicate WHO/WHERE/dimension binding." + "invalid_expose": "Invalid expose configuration or duplicate WHO/WHERE/dimension binding.", + "who_injection_unsupported": "This gateway firmware does not support injecting frames for the selected WHO subsystem onto the SCS bus (supported on all gateways: WHO 1, 2, 13).", + "who4_probe_where_unsupported": "This gateway only translates WHO 4 #0 temperature writes on unused zone master addresses 1..99 (e.g. 50); 3-digit probe addresses (100..900) are rejected by firmware." }, "abort": { "reconfigure_successful": "Exposed entity binding updated successfully." diff --git a/custom_components/myhome/translations/fr.json b/custom_components/myhome/translations/fr.json index 74f7a96ab..ed897b19d 100644 --- a/custom_components/myhome/translations/fr.json +++ b/custom_components/myhome/translations/fr.json @@ -150,7 +150,9 @@ "where_required": "Une adresse WHERE est requise pour ce sous-système WHO.", "entity_required": "Une entité Home Assistant source est requise pour ce sous-système WHO.", "invalid_dimension": "Dimension non valide pour le sous-système WHO sélectionné.", - "invalid_expose": "Configuration non valide ou liaison WHO/WHERE/dimension déjà existante." + "invalid_expose": "Configuration non valide ou liaison WHO/WHERE/dimension déjà existante.", + "who_injection_unsupported": "Le firmware de cette passerelle ne permet pas d'injecter des trames pour le sous-système WHO sélectionné sur le bus SCS (pris en charge sur toutes les passerelles : WHO 1, 2, 13).", + "who4_probe_where_unsupported": "Cette passerelle ne traduit les écritures de température WHO 4 #0 que sur les adresses maîtres de zone inutilisées 1..99 (ex. 50) ; les adresses de sonde à 3 chiffres (100..900) sont rejetées par le firmware." }, "abort": { "reconfigure_successful": "Liaison de l'entité exposée mise à jour avec succès." diff --git a/custom_components/myhome/translations/it.json b/custom_components/myhome/translations/it.json index 2d75779e2..fbbd31665 100644 --- a/custom_components/myhome/translations/it.json +++ b/custom_components/myhome/translations/it.json @@ -150,7 +150,9 @@ "where_required": "È richiesto un indirizzo WHERE per questo sottosistema WHO.", "entity_required": "È richiesta un'entità Home Assistant sorgente per questo sottosistema WHO.", "invalid_dimension": "Dimensione non valida per il sottosistema WHO selezionato.", - "invalid_expose": "Configurazione non valida o associazione WHO/WHERE/dimensione duplicata." + "invalid_expose": "Configurazione non valida o associazione WHO/WHERE/dimensione duplicata.", + "who_injection_unsupported": "Il firmware di questo gateway non supporta l'iniezione di frame per il sottosistema WHO selezionato sul bus SCS (supportati su tutti i gateway: WHO 1, 2, 13).", + "who4_probe_where_unsupported": "Questo gateway traduce le scritture di temperatura WHO 4 #0 solo sugli indirizzi sonda master di zona non utilizzati 1..99 (es. 50); gli indirizzi sonda a 3 cifre (100..900) vengono rifiutati dal firmware." }, "abort": { "reconfigure_successful": "Associazione dell'entità esposta aggiornata correttamente." diff --git a/custom_components/myhome/translations/nl.json b/custom_components/myhome/translations/nl.json index 3c42c78fb..686a8c5e3 100644 --- a/custom_components/myhome/translations/nl.json +++ b/custom_components/myhome/translations/nl.json @@ -150,7 +150,9 @@ "where_required": "Een WHERE-adres is vereist voor dit WHO-subsysteem.", "entity_required": "Een Home Assistant-bronentiteit is vereist voor dit WHO-subsysteem.", "invalid_dimension": "Ongeldige dimensie voor het geselecteerde WHO-subsysteem.", - "invalid_expose": "Ongeldige configuratie of dubbele WHO/WHERE/dimensie-koppeling." + "invalid_expose": "Ongeldige configuratie of dubbele WHO/WHERE/dimensie-koppeling.", + "who_injection_unsupported": "De firmware van deze gateway ondersteunt het injecteren van frames voor het geselecteerde WHO-subsysteem op de SCS-bus niet (ondersteund op alle gateways: WHO 1, 2, 13).", + "who4_probe_where_unsupported": "Deze gateway vertaalt WHO 4 #0-temperatuurschrijfopdrachten alleen op ongebruikte zonemasteradressen 1..99 (bijv. 50); 3-cijferige sensoradressen (100..900) worden door de firmware geweigerd." }, "abort": { "reconfigure_successful": "Koppeling van aangeboden entiteit succesvol bijgewerkt." diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py index 43b708343..72d1bc17c 100644 --- a/custom_components/myhome/validate.py +++ b/custom_components/myhome/validate.py @@ -955,10 +955,13 @@ def _validate_expose_action(val: typing.Any) -> list[dict[str, typing.Any]]: ) -# External probe N00 (N = 1..9) or individual probe PZZ (P = 1..8, ZZ = 01..99). -# 0, 1..99 and 001..099 address all probes, a zone's master probe and all probes -# of a zone, so a virtual probe must not answer on them. -_WHO4_EXPOSE_PROBE_RE = re.compile(r"(?:[1-9]00|[1-8](?:0[1-9]|[1-9]\d))") +# Master probe Z of zone 1..99 (required for #0 temperature writes on bt_termo +# gateways: F461 020011, F460 020012, and MyHomeServer1 028206 translate 1..99 +# to SCS D1 03 02 F0 00 and silently NACK >= 100), external probe +# N00 (N = 1..9), or individual probe PZZ (P = 1..8, ZZ = 01..99). +# 0 and 001..099 address all probes and all probes of a zone, and #Z addresses +# the central unit's zone object, so a virtual probe must not answer on them. +_WHO4_EXPOSE_PROBE_RE = re.compile(r"(?:[1-9]\d?|[1-9]00|[1-8](?:0[1-9]|[1-9]\d))") _WHO18_EXPOSE_WHERE_RE = re.compile( r"(?:5(?:[1-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])(?:#\d)?|7(?:[1-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])#0)" ) @@ -966,7 +969,7 @@ def _validate_expose_action(val: typing.Any) -> list[dict[str, typing.Any]]: def _validate_expose_entry(item: dict[str, typing.Any]) -> dict[str, typing.Any]: """Validate cross-field constraints on a single expose entry.""" - from .where_grammar import is_apl_address, is_probe, normalize_expose_where + from .where_grammar import is_apl_address, normalize_expose_where who = int(item[CONF_WHO]) raw_where_val = item.get(CONF_WHERE) @@ -1026,9 +1029,9 @@ def _validate_expose_entry(item: dict[str, typing.Any]) -> dict[str, typing.Any] f"WHO={who} expose 'where' must be a point-to-point actuator address (got {raw_where!r})" ) elif who == 4: - if not (is_probe(where) and _WHO4_EXPOSE_PROBE_RE.fullmatch(where)): + if not _WHO4_EXPOSE_PROBE_RE.fullmatch(where): raise Invalid( - f"WHO=4 expose 'where' must be a probe address (external probe '100'..'900' or probe PZZ '101'..'899', got {raw_where!r})" + f"WHO=4 expose 'where' must be a probe address (zone master probe '1'..'99', external probe '100'..'900', or probe PZZ '101'..'899', got {raw_where!r})" ) if dim is not None and int(dim) != 0: raise Invalid( diff --git a/custom_components/myhome/websocket.py b/custom_components/myhome/websocket.py index 4e10686c3..bb9437c26 100644 --- a/custom_components/myhome/websocket.py +++ b/custom_components/myhome/websocket.py @@ -6,6 +6,7 @@ from __future__ import annotations import logging +from collections.abc import Mapping from typing import Any, Optional import voluptuous as vol @@ -122,7 +123,7 @@ def _extract_gateway_info(gw: Optional[Any], ownd_version: str = "unknown") -> d config_entry = getattr(gw, "config_entry", None) config_data = getattr(config_entry, "data", {}) if config_entry else {} - if not isinstance(config_data, dict): + if not isinstance(config_data, Mapping): config_data = {} if not model and isinstance(config_data.get(CONF_NAME), str): diff --git a/docs/configuration/expose.md b/docs/configuration/expose.md index c8c55bac2..280a896a0 100644 --- a/docs/configuration/expose.md +++ b/docs/configuration/expose.md @@ -12,12 +12,12 @@ By emulating virtual OpenWebNet devices, physical wall controls, touchscreens (s Tap an icon or slider on a Legrand `067292` or BTicino `H4890` wall screen to control a Zigbee, Matter, or Wi-Fi device in Home Assistant. Changes made in Home Assistant or voice assistants instantly update the status icon and LED on the wall screen. 2. **Unpopulated Physical Wall Switches (`WHO = 1`)**: Use an unused rocker on an `H4652/2` or `K4652M2` physical wall control configured as a point-to-point light (`WHO = 1`) to toggle a TV, media player, or smart bulb with full LED status feedback. -3. **Virtual Temperature Probes (`WHO = 4`)**: - Publish an outdoor weather service temperature, Zigbee room sensor, or ESPHome probe onto an SCS probe address (`DIMENSION 0`), allowing wall screens and thermostats to display real-time temperatures on their Thermoregulation pages. -4. **Virtual Energy & Power Meters (`WHO = 18`)**: - Publish live solar inverter generation, battery power, or grid smart-meter metrics onto SCS energy addresses (`DIMENSION 113` for active power in Watts; `DIMENSION 51..54` for totalizers in Wh), displaying live consumption on wall screens. +3. **Virtual Temperature Probes (`WHO = 4`, gateway-dependent)**: + On gateways whose `bt_termo` daemon translates `#0` temperature writes onto the SCS bus (verified on `F461`), publish an outdoor weather service temperature, Zigbee room sensor, or ESPHome probe onto an unused zone master address (`1..99`, `DIMENSION 0`). *(Rejected by `F454`, `MH200N`, `MH202`, and wall touchscreens such as `067292` / `H4890` — see [Gateway Compatibility](#gateway-compatibility-firmware-verification) below.)* +4. **Virtual Energy & Power Meters (`WHO = 18`, gateway-dependent)**: + On gateways verified for `WHO = 18` dimension reports (`F461`), publish active power (`DIMENSION 113`, Watts) or energy totalizers (`DIMENSION 51..54`, Wh) onto OpenWebNet monitor sessions. *(Cannot inject synthetic power/energy onto a physical 2-wire SCS bus for `067292` / `H4890` wall touchscreens — see [Gateway Compatibility](#gateway-compatibility-firmware-verification) below.)* 5. **Bus Clock & Calendar Synchronization (`WHO = 13`)**: - Keep all wall screens, thermostats, and chronothermostats on the SCS bus synchronized to Home Assistant's local time, date, and daylight saving time (DST). + Keep all wall screens (`067292`, `H4890`, `10"` touchscreens), thermostats, and chronothermostats on the SCS bus synchronized to Home Assistant's local time, date, and daylight saving time (DST) across all gateway models. --- @@ -38,7 +38,7 @@ In Home Assistant, exposed bindings are configured as **Config Subentries** on y | Field | Description | Example | | :--- | :--- | :--- | | **WHO Subsystem** | The OpenWebNet domain to emulate (`1`, `2`, `4`, `13`, or `18`). | `WHO 1 — Lighting / Switch / Virtual Actuator` | -| **WHERE Address** | Unused SCS address assigned to the button/tile in *MyHOME_Suite*. *(Omit for WHO 13)* | `25` or `100` | +| **WHERE Address** | Unused SCS address assigned to the button/tile in *MyHOME_Suite*. *(Omit for WHO 13)* | `25` or `50` | | **Source Entity** | The Home Assistant entity providing state and commands. | `light.hue_living_room` | | **State Attribute** *(Optional)* | Entity attribute providing the value (e.g. `brightness`, `current_position`). | `brightness` | | **OpenWebNet Dimension** *(Optional)* | Dimension selector (`0` for temperature; `113` for power; `22` for clock). | `0` | @@ -82,10 +82,11 @@ myhome: # ----------------------------------------------------------------- # 3. Virtual Temperature Probe (WHO = 4, Dimension 0) - # Publishes outdoor temperature onto probe address 100 for wall screens. + # Publishes outdoor temperature onto unused zone master address 50 + # (1..99 required on F461 / bt_termo; 100..900 only on reply-injection gateways). # ----------------------------------------------------------------- - who: 4 - where: "100" + where: "50" dimension: 0 entity_id: sensor.outdoor_temperature cooldown: 5.0 @@ -228,19 +229,19 @@ Different gateway models handle bus frame injection differently: | OpenWebNet Domain | Gateway Compatibility | Required Frame Form | Notes | | :--- | :---: | :--- | :--- | -| **`WHO = 1` (Lights / Switches)** | **All Gateways** | `*#1*WHERE*#1*LEVEL*0##` (or `*1*1*WHERE##` / `*1*0*WHERE##`) | Universal support across F454, F455, MH200N, MH201, MH202, MHS1, 067292, and H4890. | +| **`WHO = 1` (Lights / Switches)** | **All Gateways** | `*#1*WHERE*#1*LEVEL*0##` (or `*1*1*WHERE##` / `*1*0*WHERE##`) | Universal support across F454, F455, F459, MH200N, MH201, MH202, MHS1, F461, 067292, and H4890. | | **`WHO = 2` (Covers / Shutters)** | **All Gateways** | `*#2*WHERE*#11#001*POS##` (or `*2*1/2/0*WHERE##`) | Universal support; translation echoes are cleanly absorbed. | -| **`WHO = 13` (Clock & DST)** | **All Gateways** | `*#13**#22*H*M*S*T*W*D*M*Y##` | Synchronizes on-screen clocks across all models. Set `periodic_send: 0` for query-only mode. | -| **`WHO = 4` (Virtual Temperature)** | Verified on **`F461`** | `*#4*WHERE*#0*TTTT##` | Gated by firmware injection capabilities. On gateways that reject injected probe values, a warning is logged at startup. | -| **`WHO = 18` (Virtual Energy Meter)** | Verified on **`F461`** | `*#18*WHERE*113*WATT##` | Gated by firmware injection capabilities. On gateways that reject energy reports, a warning is logged at startup. | +| **`WHO = 13` (Clock & DST)** | **All Gateways** | `*#13**#22*H*M*S*T*W*D*M*Y##` | Synchronizes on-screen clocks across all models (`067292`, `H4890`, `F454`, etc.). Set `periodic_send: 0` for query-only mode. | +| **`WHO = 4` (Virtual Temperature)** | Verified on **`F461`** (`WHERE` `1..99`) | `*#4*WHERE*#0*TTTT##` | `bt_termo` (`F461`) translates unused zone master addresses `1..99` (e.g. `"50"`) onto the SCS bus (`D1 03 02 F0 00`), but rejects 3-digit probe addresses (`100..900`). Rejected (`NACK`) for all addresses on `F454`, `F459`, `MH200N`, `MH202`, `067292`, `H4890`, and `H4684`. | +| **`WHO = 18` (Virtual Energy Meter)** | Verified on **`F461`** (TCP session only) | `*#18*WHERE*113*WATT##` | Rejected (`NACK`) by wall touchscreens (`067292`, `H4890`, `H4684`). `bt_supervisione` / `bt_energy` gateways (`F454`, `F459`, `MH200N`, `MH202`, `MHS1`, `F461`) `ACK` on TCP but emit an SCS active-power **read** request (`D1 A1 02 33 00 1D 00 00`), discarding the value — so synthetic `WHO = 18` readings cannot reach physical 2-wire SCS wall screens. | > [!NOTE] -> For physical wall touchscreens (Legrand `067292`, Arteor `573958`, BTicino `H4890`, `LN4890`), configure the button or tile with an unused address in *MyHOME_Suite*, then create the matching expose binding in Home Assistant. +> For physical wall touchscreens (Legrand `067292`, Arteor `573958`, BTicino `H4890`, `LN4890`), configure virtual light/switch (`WHO = 1`), shutter (`WHO = 2`), or scenario tiles with unused point-to-point addresses in *MyHOME_Suite*, and add clock sync (`WHO = 13`). On a shared SCS bus with a primary gateway (e.g. `F454` + `067292`), create the expose subentries on the **primary** gateway and keep `WHO = 4` / `WHO = 18` undelegated on the primary gateway. --- ## 🔍 Diagnostics & Troubleshooting - **Check Active Bindings**: Open **Settings → Devices & Services → MyHOME → Download Diagnostics**. The JSON file contains an `"expose"` section detailing all active bindings, throttles, and last-sent timestamps. -- **Unverified Gateway Warning**: If a warning like *"Expose WHO=4 WHERE=100 is disabled: gateway ... is unverified for WHO 4 #0 writes"* appears in your Home Assistant log, the gateway firmware does not support injected sensor frames. +- **Unsupported / Unverified Gateway Warning**: The UI Config Subentry flow hides `WHO = 4` and `WHO = 18` on gateways whose firmware cannot inject them onto the SCS bus, and rejects 3-digit `WHO = 4` probe addresses (`100..900`) on `#0`-only gateways (`F461`). If a legacy YAML or pre-existing binding targets an unsupported gateway or address, a single warning is logged at startup (*"Expose WHO=... is disabled: gateway ... Nothing is sent for this binding"*). - **Duplicate Address Error (`invalid_expose`)**: Each `(who, where, dimension)` triplet must be unique per gateway. diff --git a/tests/test_expose.py b/tests/test_expose.py index a23933e29..58fcde441 100644 --- a/tests/test_expose.py +++ b/tests/test_expose.py @@ -206,9 +206,17 @@ def test_expose_schema_validation(): with pytest.raises(vol.Invalid, match="point-to-point actuator address"): expose_entry_schema({"who": 1, "where": "0", "entity_id": "light.test"}) - # WHO=4 invalid WHERE (zone 1..99 not a probe) - with pytest.raises(vol.Invalid, match="must be a probe address"): - expose_entry_schema({"who": 4, "where": "1", "entity_id": "sensor.temp"}) + # WHO=4 invalid WHERE (all-probes 0 / zone all-probes 001..099 / central #Z not a probe) + for bad_w4 in ("0", "001", "050", "#1", "#50"): + with pytest.raises(vol.Invalid, match="must be a probe address"): + expose_entry_schema({"who": 4, "where": bad_w4, "entity_id": "sensor.temp"}) + + # WHO=4 valid zone master probe WHERE 1..99 (required for bt_termo #0 writes) + for ok_w4 in ("1", "50", "99"): + assert ( + expose_entry_schema({"who": 4, "where": ok_w4, "entity_id": "sensor.temp"})["where"] + == ok_w4 + ) # WHO=4 invalid dimension with pytest.raises(vol.Invalid, match="supports only dimension 0"): @@ -566,7 +574,7 @@ async def test_who4_virtual_temperature_probe_and_deadband(hass: HomeAssistant): expose_list=[ { "who": 4, - "where": "100", + "where": "50", "dimension": 0, "entity_id": "sensor.outdoor_temp", "cooldown": 0, @@ -579,9 +587,9 @@ async def test_who4_virtual_temperature_probe_and_deadband(hass: HomeAssistant): await mgr.async_start() await mgr.async_sync_all() - # Initial sync sends *#4*100*#0*0215## via low-priority queue + # Initial sync sends *#4*50*#0*0215## via low-priority queue assert [str(c.args[0]) for c in gw.send_status_request.call_args_list] == [ - "*#4*100*#0*0215##" + "*#4*50*#0*0215##" ] gw.send_status_request.reset_mock() @@ -594,13 +602,13 @@ async def test_who4_virtual_temperature_probe_and_deadband(hass: HomeAssistant): hass.states.async_set("sensor.outdoor_temp", "-3.2") await hass.async_block_till_done() assert [str(c.args[0]) for c in gw.send_status_request.call_args_list] == [ - "*#4*100*#0*1032##" + "*#4*50*#0*1032##" ] - # Bus query *#4*100*0## replies on high-priority queue with current value (-3.2°C -> 1032) + # Bus query *#4*50*0## replies on high-priority queue with current value (-3.2°C -> 1032) gw.send.reset_mock() - await mgr.handle_bus_message(OWNMessage.parse("*#4*100*0##")) - assert [str(c.args[0]) for c in gw.send.call_args_list] == ["*#4*100*#0*1032##"] + await mgr.handle_bus_message(OWNMessage.parse("*#4*50*0##")) + assert [str(c.args[0]) for c in gw.send.call_args_list] == ["*#4*50*#0*1032##"] await mgr.async_stop() @@ -777,6 +785,7 @@ def test_discovery_suppresses_phantom_entities_for_exposed_addresses(hass: HomeA mgr = ExposeManager(hass, entry) entry.runtime_data.expose_manager = mgr + entry.runtime_data.platforms["climate"] = {} added_lights: list = [] @@ -864,6 +873,7 @@ async def test_button_platform_skips_exposed_virtual_actuators(hass: HomeAssista ) mgr = ExposeManager(hass, entry) entry.runtime_data.expose_manager = mgr + entry.runtime_data.platforms["climate"] = {} entry.runtime_data.platforms["button"] = { "99": {"where": "99", "who": "1", "name": "Virtual TV Switch"}, "12": {"where": "12", "who": "1", "name": "Physical Relay"}, @@ -1052,12 +1062,29 @@ async def test_expose_subentry_flow_and_diagnostics(hass: HomeAssistant): assert clock_res["type"] == FlowResultType.CREATE_ENTRY assert "Bus Clock Sync" in clock_res["title"] - # Submitting valid WHO=4 probe binding succeeds + # On F461 (#0-only bt_termo), 3-digit probe WHERE="100" is rejected in the UI flow + bad_probe_res = await flow.async_step_user( + { + CONF_WHO: "4", + "where": "100", + CONF_DIMENSION: 0, + "entity_id": "sensor.outdoor_temp", + CONF_COOLDOWN: 30, + CONF_MIN_DELTA: 0.1, + CONF_PERIODIC_SEND: 900, + CONF_RESPOND_TO_READ: True, + CONF_BRIGHTNESS_STEP_PCT: 10, + } + ) + assert bad_probe_res["type"] == FlowResultType.FORM + assert bad_probe_res["errors"]["where"] == "who4_probe_where_unsupported" + + # Submitting valid WHO=4 master-probe binding (WHERE="50") succeeds on F461 with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload: ok_res = await flow.async_step_user( { CONF_WHO: "4", - "where": "100", + "where": "50", CONF_DIMENSION: 0, "entity_id": "sensor.outdoor_temp", CONF_COOLDOWN: 30, @@ -1070,7 +1097,7 @@ async def test_expose_subentry_flow_and_diagnostics(hass: HomeAssistant): assert mock_reload.called assert ok_res["type"] == FlowResultType.CREATE_ENTRY assert ok_res["data"]["who"] == 4 - assert ok_res["data"]["where"] == "100" + assert ok_res["data"]["where"] == "50" assert ok_res["data"]["dimension"] == 0 # Reconfigure step on existing subentry "sub_1" @@ -1158,14 +1185,14 @@ async def test_expose_templates_attributes_defaults_and_unit_scaling(hass: HomeA }, { "who": 4, - "where": "101", + "where": "51", "entity_id": "sensor.unavailable_temp", "default": 19.5, "cooldown": 0, }, { "who": 4, - "where": "102", + "where": "52", "entity_id": "climate.nest", "attribute": "current_temperature", "value_template": "{{ (value | float) - 0.4 }}", @@ -1183,8 +1210,8 @@ async def test_expose_templates_attributes_defaults_and_unit_scaling(hass: HomeA assert "*#18*51*52*120500##" in sent assert "*#18*51*53*4500##" in sent assert "*#18*51*54*890##" in sent - assert "*#4*101*#0*0195##" in sent - assert "*#4*102*#0*0220##" in sent + assert "*#4*51*#0*0195##" in sent + assert "*#4*52*#0*0220##" in sent await mgr.async_stop() @@ -1870,13 +1897,19 @@ def _capture_call_later(_hass, _delay, action): mgr_mh200n = ExposeManager(hass, entry_empty, gateway=gw_mh200n) hass.states.async_set("sensor.temp_ok", "21.5") hass.states.async_set("sensor.temp_bad", "not_a_float") - b_w4 = ExposeBinding(who=4, where="100", entity_id="sensor.temp_ok") + b_w4 = ExposeBinding(who=4, where="50", entity_id="sensor.temp_ok") f455_frames, f455_val = mgr_f455._build_frames(b_w4, force=True, is_reply=False) - assert [str(f) for f in f455_frames] == ["*#4*100*#0*0215##"] + assert [str(f) for f in f455_frames] == ["*#4*50*#0*0215##"] assert f455_val == 21.5 + # 3-digit probe WHERE >= 100 is blocked on #0-only gateways (supports_dimension_response_injection=False) + assert mgr_f455._build_frames( + ExposeBinding(who=4, where="100", entity_id="sensor.temp_ok"), + force=True, + is_reply=False, + ) == ([], None) assert mgr_mh200n._build_frames(b_w4, force=True, is_reply=False) == ([], None) assert mgr_t._build_frames( - ExposeBinding(who=4, where="100", entity_id="sensor.temp_bad"), + ExposeBinding(who=4, where="50", entity_id="sensor.temp_bad"), force=True, is_reply=False, ) == ([], None) @@ -2078,6 +2111,7 @@ async def test_audit_regressions_echo_futures_brightness_off_scope_and_actions(h ) mgr = ExposeManager(hass, entry) entry.runtime_data.expose_manager = mgr + entry.runtime_data.platforms["climate"] = {} await mgr.async_start() await mgr.async_sync_all() @@ -2346,6 +2380,7 @@ async def test_who2_oracle_40_step_echo_loop_simulation(hass: HomeAssistant): ) mgr = ExposeManager(hass, entry) entry.runtime_data.expose_manager = mgr + entry.runtime_data.platforms["climate"] = {} await mgr.async_start() bus_q: list[OWNMessage] = [] @@ -2486,18 +2521,18 @@ async def test_expose_infeasible_inputs_hardening(hass: HomeAssistant): assert is_probe("-105") is False assert is_probe("###105") is False - # WHO=4 group and zone addresses are not probes: all probes (0), a zone's master - # probe (1..99), all probes of a zone (001..099), probe P=9 (only 1..8 exist) - for group_where in (0, "0", "000", "050", "099", "50", "#50", "901", "999"): + # WHO=4 group and central-zone addresses are not probes: all probes (0), + # all probes of a zone (001..099), central zone (#50), probe P=9 (only 1..8 exist) + for group_where in (0, "0", "000", "050", "099", "#50", "901", "999"): with pytest.raises(vol.Invalid, match="must be a probe address"): expose_entry_schema({"who": 4, "where": group_where, "entity_id": "sensor.t"}) - # Valid local-bus WHO=1, WHO=4 external probes, WHO=13 with where=None, and valid WHO=4/18 defaults pass + # Valid local-bus WHO=1, WHO=4 master/external/PZZ probes, WHO=13 with where=None, and valid WHO=4/18 defaults pass valid_local = expose_entry_schema( {"who": 1.0, "where": "99#4#01", "entity_id": "light.a"} ) assert valid_local["where"] == "99#4#01" - for probe_where, expected in ((100, "100"), ("#900", "900"), ("899", "899")): + for probe_where, expected in (("50", "50"), (100, "100"), ("#900", "900"), ("899", "899")): valid_probe = expose_entry_schema( {"who": 4, "where": probe_where, "entity_id": "sensor.t"} ) @@ -2564,7 +2599,7 @@ async def test_expose_infeasible_inputs_hardening(hass: HomeAssistant): {"who": 1, "where": "96", "entity_id": "light.bad_b", "attribute": "brightness"}, {"who": 1, "where": "95", "entity_id": "switch.no_b"}, {"who": 2, "where": "97", "entity_id": "cover.bad_p", "attribute": "current_position"}, - {"who": 4, "where": "100", "entity_id": "sensor.bad_t"}, + {"who": 4, "where": "50", "entity_id": "sensor.bad_t"}, {"who": 18, "where": "51", "dimension": 113, "entity_id": "sensor.bad_e"}, {"who": 13}, ], @@ -2955,7 +2990,7 @@ async def test_unverified_gateway_disables_who4_who18_with_one_warning( hass.states.async_set("sensor.power", "450") hass.states.async_set("light.lamp", "on") expose_list = [ - {"who": 4, "where": "150", "entity_id": "sensor.temp"}, + {"who": 4, "where": "50", "entity_id": "sensor.temp"}, {"who": 18, "where": "51", "dimension": 113, "entity_id": "sensor.power"}, {"who": 1, "where": "25", "entity_id": "light.lamp"}, ] @@ -2971,7 +3006,7 @@ async def test_unverified_gateway_disables_who4_who18_with_one_warning( assert sent == ["*1*1*25##", "*1*1*25##"] warnings = [r.getMessage() for r in caplog.records if "is disabled" in r.getMessage()] assert warnings == [ - "Expose WHO=4 WHERE=150 dimension=0 (sensor.temp) is disabled: gateway MH202 is unverified for " + "Expose WHO=4 WHERE=50 dimension=0 (sensor.temp) is disabled: gateway MH202 is unverified for " "WHO 4 #0 temperature writes (supports_who4_command_injection is unset in OWNd). " "Nothing is sent for this binding.", "Expose WHO=18 WHERE=51 dimension=113 (sensor.power) is disabled: gateway MH202 is unverified for " @@ -2979,6 +3014,14 @@ async def test_unverified_gateway_disables_who4_who18_with_one_warning( "Nothing is sent for this binding.", ] + # WHO=18 Dim 1200 query & streaming handshake are also suppressed while WHO=18 is blocked + gw.send.reset_mock() + gw.send_status_request.reset_mock() + await mgr.handle_bus_message(OWNMessage.parse("*#18*51*1200##")) + await mgr.handle_bus_message(OWNMessage.parse("*#18*51*#1200#1*5##")) + assert gw.send.call_count == 0 + assert gw.send_status_request.call_count == 0 + # A gateway that rejects the frames, or no profile yet, gives its own reason gw.profile = SimpleNamespace( model_name="MH200N", @@ -3001,11 +3044,24 @@ async def test_unverified_gateway_disables_who4_who18_with_one_warning( caplog.clear() await mgr.async_sync_all() assert [str(c.args[0]) for c in gw.send_status_request.call_args_list] == [ - "*#4*150*#0*0215##", + "*#4*50*#0*0215##", "*#18*51*113*450##", "*1*1*25##", ] assert "is disabled" not in caplog.text + + # A 3-digit probe address (WHERE=150) on F461 logs the bt_termo 1..99 restriction once + mgr.bindings[0].where = "150" + gw.send_status_request.reset_mock() + caplog.clear() + await mgr.async_sync_all() + await mgr.async_sync_all() + probe_warnings = [r.getMessage() for r in caplog.records if "is disabled" in r.getMessage()] + assert probe_warnings == [ + "Expose WHO=4 WHERE=150 dimension=0 (sensor.temp) is disabled: gateway F461 only translates " + "WHO 4 #0 temperature writes on zone master probe addresses 1..99 " + "(3-digit WHERE=150 is rejected by firmware). Nothing is sent for this binding." + ] await mgr.async_stop() @@ -3079,3 +3135,129 @@ async def _record_call(call: Any) -> None: assert mgr.bindings[1].last_sent_value == (True, 1) await mgr.async_stop() + + +@pytest.mark.asyncio +async def test_expose_subentry_flow_hides_and_rejects_unsupported_gateway_whos( + hass: HomeAssistant, +) -> None: + """Gateways without WHO 4/18 injection (e.g. 067292 / H4890 / MH202) hide and reject WHO 4 & 18 (#832).""" + from custom_components.myhome.expose import effective_expose_profile + + # Auto-created MagicMock gateway profiles (no str model_name and no bool injection flags) resolve to None + assert effective_expose_profile(MagicMock(), 4) is None + + gw_h4890 = _make_gateway( + profile=SimpleNamespace( + model_name="H4890", + supports_dimension_response_injection=False, + supports_who4_command_injection=None, + supports_who18_response_injection=None, + ) + ) + entry = _make_entry( + hass, + gateway=gw_h4890, + subentries=[ + { + "subentry_type": SUBENTRY_TYPE_EXPOSE, + "subentry_id": "sub_legacy_w4", + "title": "Exposed WHO 4 WHERE 50", + "unique_id": None, + "data": {"who": 4, "where": "50", "entity_id": "sensor.temp"}, + } + ], + ) + mgr = ExposeManager(hass, entry) + entry.runtime_data.expose_manager = mgr + entry.runtime_data.platforms["climate"] = {} + + flow = ExposeSubentryFlowHandler() + flow.hass = hass + flow.handler = (entry.entry_id, SUBENTRY_TYPE_EXPOSE) + flow.context = {"source": "user"} + + form = await flow.async_step_user(None) + who_selector = next(iter(form["data_schema"].schema.values())) + who_values = [opt["value"] for opt in who_selector.config["options"]] + assert who_values == ["1", "2", "13"] + + # Submitting WHO=4 or WHO=18 on H4890 returns who_injection_unsupported + for blocked_who in ("4", "18"): + res = await flow.async_step_user( + { + CONF_WHO: blocked_who, + "where": "51", + CONF_DIMENSION: 113 if blocked_who == "18" else 0, + "entity_id": "sensor.temp", + } + ) + assert res["type"] == FlowResultType.FORM + assert res["errors"][CONF_WHO] == "who_injection_unsupported" + + # Reconfiguring a pre-existing WHO=4 subentry keeps "4" in options so the form renders cleanly + flow.context = {"source": "reconfigure", "subentry_id": "sub_legacy_w4"} + reconf_form = await flow.async_step_reconfigure(None) + reconf_selector = next(iter(reconf_form["data_schema"].schema.values())) + reconf_values = [opt["value"] for opt in reconf_selector.config["options"]] + assert reconf_values == ["1", "2", "4", "13"] + + # When runtime_data is unloaded/None, _gateway_profile_for_who returns None and all 5 WHOs appear + entry.runtime_data = None + flow.context = {"source": "user"} + unloaded_form = await flow.async_step_user(None) + unloaded_selector = next(iter(unloaded_form["data_schema"].schema.values())) + unloaded_values = [opt["value"] for opt in unloaded_selector.config["options"]] + assert unloaded_values == ["1", "2", "4", "13", "18"] + + +@pytest.mark.asyncio +async def test_climate_discovery_defers_exposed_who4_master_probe_until_real_zone_frame( + hass: HomeAssistant, +) -> None: + """Exposed WHO=4 master probes (1..99) do not spawn phantom climate zones, yet preserve real zones (#832).""" + from homeassistant.helpers.dispatcher import async_dispatcher_send + + from custom_components.myhome.climate import async_setup_entry as climate_setup_entry + + gw = _make_gateway() + entry = _make_entry( + hass, + gateway=gw, + expose_list=[ + {"who": 4, "where": "50", "entity_id": "sensor.outdoor_temp"}, + {"who": 4, "where": "5", "entity_id": "sensor.living_temp"}, + {"who": 4, "where": "6#4#01", "entity_id": "sensor.annex_temp"}, + ], + ) + mgr = ExposeManager(hass, entry) + entry.runtime_data.expose_manager = mgr + entry.runtime_data.platforms["climate"] = {} + + added_climates: list[Any] = [] + + def _add_entities(entities: list[Any]) -> None: + for ent in entities: + ent.hass = hass + added_climates.append(ent) + + await climate_setup_entry(hass, entry, _add_entities) + + async def _emit(raw: str) -> None: + async_dispatcher_send(hass, f"myhome_message_{gw.mac}", OWNHeatingEvent.parse(raw)) + await hass.async_block_till_done() + + # 1. Pure virtual master probes on unused zones 50 and 6#4#01 must NOT create phantom climate entities + await _emit("*#4*50*0*0215##") + await _emit("*#4*6#4#01*0*0195##") + # Unexposed zone 8 still discovers normally from a Dimension 0 temperature report + await _emit("*#4*8*0*0204##") + assert [ent._where for ent in added_climates] == ["8"] + + # 2. Zone 5 receives exposed master-probe temperature first, then a real zone setpoint report -> + # Zone 5 discovers normally AND retains the deferred 21.5°C probe temperature! + await _emit("*#4*5*0*0215##") + assert [ent._where for ent in added_climates] == ["8"] + await _emit("*#4*5*14*0210*1##") + assert [ent._where for ent in added_climates] == ["8", "5"] + assert added_climates[1].current_temperature == 21.5 diff --git a/tests/test_legacy_yaml.py b/tests/test_legacy_yaml.py index 543189e72..37a3335ee 100644 --- a/tests/test_legacy_yaml.py +++ b/tests/test_legacy_yaml.py @@ -522,3 +522,79 @@ async def test_legacy_yaml_mac_mismatch_skips_other_entry_without_mac( assert "00:03:50:81:22:33" in warnings[0] assert "00:03:50:00:00:00" in warnings[0] assert "None" not in warnings[0] + + +async def test_legacy_yaml_mappingproxy_entries_and_unique_id_fallback_do_not_warn( + hass: HomeAssistant, tmp_path, caplog +): + """Real ConfigEntry.data is a MappingProxyType, not a dict (#832).""" + from types import MappingProxyType + + from pytest_homeassistant_custom_component.common import MockConfigEntry + + from custom_components.myhome.const import DOMAIN + + yaml_file = tmp_path / "myhome.yaml" + yaml_file.write_text( + "'00:03:50:aa:11:22':\n light:\n hallway:\n where: '21'\n name: 'Hallway'\n", + encoding="utf-8", + ) + primary = MockConfigEntry( + domain=DOMAIN, + data={CONF_MAC: "00:03:50:aa:11:22"}, + options={CONF_FILE_PATH: str(yaml_file)}, + unique_id="00:03:50:aa:11:22", + ) + secondary = MockConfigEntry( + domain=DOMAIN, + data={CONF_MAC: "00:03:50:bb:33:44"}, + options={CONF_FILE_PATH: str(yaml_file)}, + unique_id="00:03:50:bb:33:44", + ) + primary.add_to_hass(hass) + secondary.add_to_hass(hass) + assert isinstance(primary.data, MappingProxyType) + assert isinstance(secondary.data, MappingProxyType) + + configured_platforms = _make_configured_platforms() + with caplog.at_level("WARNING", logger="custom_components.myhome"): + await load_legacy_myhome_yaml(hass, secondary, configured_platforms) + + assert _myhome_warnings(caplog) == [] + + # Also recognise another entry whose MAC lives only on unique_id, and skip non-Mapping data + uid_only_yaml = tmp_path / "uid_only.yaml" + uid_only_yaml.write_text( + "'00:03:50:cc:55:66':\n light:\n kitchen:\n where: '22'\n name: 'Kitchen'\n", + encoding="utf-8", + ) + uid_only_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + unique_id="00:03:50:cc:55:66", + ) + uid_only_entry.add_to_hass(hass) + broken_peer = MagicMock(spec=ConfigEntry) + broken_peer.entry_id = "broken_peer" + broken_peer.data = None + secondary_uid_check = MockConfigEntry( + domain=DOMAIN, + data={CONF_MAC: "00:03:50:dd:77:88"}, + options={CONF_FILE_PATH: str(uid_only_yaml)}, + unique_id="00:03:50:dd:77:88", + ) + secondary_uid_check.add_to_hass(hass) + + with ( + caplog.at_level("WARNING", logger="custom_components.myhome"), + patch.object( + hass.config_entries, + "async_entries", + return_value=[secondary_uid_check, broken_peer, uid_only_entry], + ), + ): + await load_legacy_myhome_yaml( + hass, secondary_uid_check, _make_configured_platforms() + ) + + assert _myhome_warnings(caplog) == [] diff --git a/tests/test_websocket.py b/tests/test_websocket.py index d67ac4b71..157a71704 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -551,6 +551,38 @@ def test_extract_gateway_info_edge_cases(): gw_buf_err.config_entry = None assert _extract_gateway_info(gw_buf_err)["queue_depth"] == 0 + # 8. Real ConfigEntry with MappingProxyType data (#832) + from types import MappingProxyType + + from pytest_homeassistant_custom_component.common import MockConfigEntry + + real_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "name": "Legrand 067292", + "host": "192.0.2.50", + "port": 20000, + "firmware": "1.2.3", + "mac": "00:03:50:aa:bb:cc", + "command_worker_count": 2, + }, + ) + assert isinstance(real_entry.data, MappingProxyType) + gw_mp = MagicMock() + gw_mp.gateway = None + gw_mp.model = None + gw_mp.mac = None + gw_mp.sending_workers = [] + gw_mp.send_buffer = None + gw_mp.config_entry = real_entry + mp_info = _extract_gateway_info(gw_mp) + assert mp_info["model"] == "Legrand 067292" + assert mp_info["host"] == "192.0.2.50" + assert mp_info["port"] == 20000 + assert mp_info["firmware"] == "1.2.3" + assert mp_info["mac_prefix"] == "00:03:50" + assert mp_info["worker_count"] == 2 + async def test_mutating_commands_require_admin(hass: HomeAssistant, attach_gateway): """Injecting frames onto the bus and wiping the buffer are admin-only."""