Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions custom_components/myhome/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
51 changes: 42 additions & 9 deletions custom_components/myhome/config_flow_expose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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,
)
),
Expand Down Expand Up @@ -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 = (
""
Expand Down Expand Up @@ -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 = (
Expand All @@ -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:
Expand Down
111 changes: 75 additions & 36 deletions custom_components/myhome/expose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)))
Expand All @@ -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)
Expand Down
13 changes: 10 additions & 3 deletions custom_components/myhome/legacy_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)]
Expand Down
4 changes: 3 additions & 1 deletion custom_components/myhome/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 3 additions & 1 deletion custom_components/myhome/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 3 additions & 1 deletion custom_components/myhome/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 3 additions & 1 deletion custom_components/myhome/translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
4 changes: 3 additions & 1 deletion custom_components/myhome/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading
Loading