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
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,27 +90,27 @@ We now maintain a comprehensive, community-curated **[GitHub Wiki](https://githu
| **MH200** *(Legacy)* | OpenWebNet | 1 worker | 150 ms | ✅ SSDP | Strict single-session pacing; watchdog hardened |
| **H4890 / AM4890** | OpenWebNet | 1 worker | 50 ms | ✅ SSDP | 3.5" Touch screen display IP gateway (Axolute / Livinglight) |
| **F452 / F453AV** | OpenWebNet | 1 worker | 150 ms | ✅ Port 49153 | Audio/video & web server gateway |
| **HL4684** | OpenWebNet | 1 worker | 50 ms | ✅ SSDP | 10" Touch screen display IP gateway |
| **H4684 / H/L4684** | OpenWebNet | 1 worker | 50 ms | ✅ SSDP | Colour touch screen IP gateway (Axolute / LivingLight) |
| **Legrand 3578** | OpenWebNet (Serial) | 1 worker | 50 ms | ❌ Manual (Serial) | USB / Serial gateway & OpenZigBee interface |
<!-- GATEWAY_PROFILES_END -->


### 📊 Hardware Trace Availability Matrix

<!-- TRACE_MATRIX_START -->
| Gateway Model | WHO 0<br>Scenario | WHO 1<br>Lights | WHO 2<br>Autom. | WHO 4<br>Climate | WHO 5<br>Alarm | WHO 9<br>Power | WHO 13<br>Gateway | WHO 14<br>Lock | WHO 15<br>CEN | WHO 16<br>Audio | WHO 17<br>Scenario | WHO 18<br>Energy | WHO 22<br>Audio Diff. | WHO 25<br>Diag | WHO 1001<br>Diag | WHO 1013<br>Diag | WHO 1022<br>Diag |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| **F453AV** | | ✅ | ✅ | ✅ | ✅ | | ✅ | | | ✅ | | ✅ | | | ✅ | | |
| **F454** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | | ✅ | | ✅ | |
| **F455** | | ✅ | ✅ | ✅ | ✅ | | ✅ | | | ✅ | | ✅ | | | | ✅ | |
| **F461** | | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | | ✅ | | ✅ | | | | ✅ | |
| **H4684** | | | | | | | ✅ | | | | | | | | | ✅ | |
| **H4890 / AM4890** | | ✅ | ✅ | ✅ | ✅ | ✅ | | | | ✅ | | ✅ | ✅ | ✅ | | | |
| **MH200** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | | | | ✅ | ✅ | |
| **MH200N** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **MH201** | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | | | |
| **MH202** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | ✅ | ✅ | | ✅ | |
| **MyHomeServer1** | | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | |
| Gateway Model | WHO 0<br>Scenario | WHO 1<br>Lights | WHO 2<br>Autom. | WHO 3<br>Load | WHO 4<br>Climate | WHO 5<br>Alarm | WHO 9<br>Power | WHO 13<br>Gateway | WHO 14<br>Lock | WHO 15<br>CEN | WHO 16<br>Audio | WHO 17<br>Scenario | WHO 18<br>Energy | WHO 22<br>Audio Diff. | WHO 25<br>Diag | WHO 1001<br>Diag | WHO 1013<br>Diag | WHO 1022<br>Diag |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| **F453AV** | | ✅ | ✅ | | ✅ | ✅ | | ✅ | | | ✅ | | ✅ | | | ✅ | | |
| **F454** | | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | | ✅ | | ✅ | |
| **F455** | | ✅ | ✅ | | ✅ | ✅ | | ✅ | | | ✅ | | ✅ | | | | ✅ | |
| **F461** | | ✅ | ✅ | | ✅ | ✅ | | ✅ | ✅ | | ✅ | | ✅ | | | | ✅ | |
| **H4684 / H/L4684** | | ✅ | ✅ | ✅ | ✅ | | | ✅ | | | ✅ | | ✅ | ✅ | | | ✅ | |
| **H4890 / AM4890** | | ✅ | ✅ | | ✅ | ✅ | ✅ | | | | ✅ | | ✅ | ✅ | ✅ | | | |
| **MH200** | | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | | | | ✅ | ✅ | |
| **MH200N** | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **MH201** | ✅ | ✅ | ✅ | | ✅ | | | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | | | |
| **MH202** | | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | ✅ | ✅ | | ✅ | |
| **MyHomeServer1** | | ✅ | ✅ | | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | |
<!-- TRACE_MATRIX_END -->

*Checkmarks (✅) indicate that at least one `diagnostic_summary.json` or `.txt` bus capture in our test corpus contains frames for that subsystem from the specified gateway model. This matrix is automatically updated from the fixtures repository.*
Expand Down
36 changes: 29 additions & 7 deletions custom_components/myhome/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ def __init__(

# Fan mode support (fancoil 3-speed + auto)
self._fan: bool = False
self._fan_configured: bool = bool(fan and not self._central)
self._attr_fan_mode: str | None = None
self._attr_fan_modes: list[str] | None = None
self._running_fan_speed: str | None = None
Expand Down Expand Up @@ -473,6 +474,18 @@ def _enable_fan_mode(self) -> None:
if self._attr_fan_mode is None:
self._attr_fan_mode = "auto"

def _has_no_fan_coil_sides(self) -> bool:
"""Whether dimension 19 reported that neither side of this zone is a fan coil."""
return bool(self._fan_coil_sides) and not any(self._fan_coil_sides.values())

def _disable_fan_mode(self) -> None:
"""Retract dynamically enabled fan mode once dimension 19 proves a pure-valve zone."""
self._fan = False
self._attr_supported_features &= ~ClimateEntityFeature.FAN_MODE
self._attr_fan_modes = None
self._attr_fan_mode = None
self._running_fan_speed = None

def _enable_humidity(self) -> None:
"""Dynamically enable humidity support if not already enabled."""
if self._central:
Expand Down Expand Up @@ -573,9 +586,10 @@ async def async_restore_last_state(self, last_state: State | None) -> None:
"heating": bool(last_state.attributes.get("fan_coil_heating", False)),
"cooling": bool(last_state.attributes.get("fan_coil_cooling", False)),
}
if "fan_mode" in last_state.attributes or bool(
last_state.attributes.get("supported_features", 0) & ClimateEntityFeature.FAN_MODE
):
if (
"fan_mode" in last_state.attributes
or bool(last_state.attributes.get("supported_features", 0) & ClimateEntityFeature.FAN_MODE)
) and not (self._has_no_fan_coil_sides() and not self._fan_configured):
self._enable_fan_mode()
restored_fan_mode = last_state.attributes.get("fan_mode")
if restored_fan_mode in ("auto", "low", "medium", "high"):
Expand Down Expand Up @@ -1379,10 +1393,18 @@ def handle_event(self, message: OWNHeatingEvent) -> None:
}
self._fan_coil_sides_asked_at = None
self._fan_coil_sides_held = False
# Actuator status (Dimension 20) with values >= 5 reports fancoil fan status in OWNd.
is_fan = isinstance(getattr(message, "fan_speed", None), int) or isinstance(
getattr(message, "fan_on", None), bool
)
if self._has_no_fan_coil_sides() and not self._fan_configured:
# Pure-valve actuator channels can report value 5 on dimension 20 (issue #466 H/L4684
# zones 11..88) while dimension 19 carries 0..4 on both sides once _ask_fan_coil_sides()
# (*#4*Z*19##) is answered (myhomeserver1_live_2026-10-25 zones 35..70, issue_674 F455):
# retract the tentative fan_mode once dimension 19 proves neither side is a fan coil.
self._disable_fan_mode()
# Actuator status (Dimension 20) with values >= 5 reports fancoil fan status in OWNd,
# unless dimension 19 already proved neither side of an unconfigured zone is a fan coil.
is_fan = (
isinstance(getattr(message, "fan_speed", None), int)
or isinstance(getattr(message, "fan_on", None), bool)
) and not (self._has_no_fan_coil_sides() and not self._fan_configured)
fan_enabled_now = is_fan and not self._fan
if is_fan:
self._enable_fan_mode()
Expand Down
8 changes: 6 additions & 2 deletions custom_components/myhome/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ def percent_to_eight_bits(value: int) -> int:
"AM4890",
"H4890",
"LN4890",
"H4684",
"Generic",
]

Expand Down Expand Up @@ -353,7 +354,11 @@ def percent_to_eight_bits(value: int) -> int:
# Shared across modern gateway families (not all Linux-based, e.g. MH201),
# so it identifies none of them (see WHO13_SHARED_DEVICE_TYPES).
# It contradicts legacy gateways (e.g. MH200/F452), but only as field evidence.
# 23: Confirmed on physical BTicino H/L4684 touchscreen gateway (issue #466
# comment 6398442412, firmware 6.0.11, kernel 2.6.2, distro 6.0.0; UPnP
# modelName `H/L4684`, distinct from 2006 spec code `13` on FW 2.0.54).
WHO13_OBSERVED_DEVICE_TYPES: dict[str, tuple[str, ...]] = {
"23": ("H4684", "H/L4684", "L4684"),
"200": ("F454", "MyHomeServer1", "MH202", "F461", "H4890", "MH201"),
}
# Codes from an independent implementation: the `device` table of Nmap's
Expand All @@ -380,7 +385,6 @@ def percent_to_eight_bits(value: int) -> int:
"12": ("F453AV",),
"15": ("F427",), # Nmap: "F427 (Gateway Open-KNX)"
"16": ("F453",),
"23": ("H4684",), # a second code for the model the 2006 table gives as 13
"27": ("L4686SDK",),
"44": ("MH200N",),
"51": ("F454",),
Expand Down Expand Up @@ -487,7 +491,7 @@ def gateway_model_family(model: str | None) -> str:
"""
if not model:
return ""
name = str(model).strip().upper().replace(" ", "").replace("-", "").replace("_", "")
name = str(model).strip().upper().replace(" ", "").replace("-", "").replace("_", "").replace("/", "")
m = re.match(r"^([A-Z]+\d+)[A-Z]*$", name)
return m.group(1) if m else name

Expand Down
2 changes: 2 additions & 0 deletions custom_components/myhome/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
CONF_DECODER_COMPANION,
CONF_DECODER_ENTITY,
CONF_DECODER_SLOTS,
CONF_FILE_PATH,
CONF_PRIMARY_GATEWAY,
DOMAIN,
INTEGRATION_VERSION,
Expand Down Expand Up @@ -53,6 +54,7 @@
"ssdp_location",
"friendly_name",
"file_path",
CONF_FILE_PATH,
CONF_PRIMARY_GATEWAY,
"primary_mac",
"secondary_mac",
Expand Down
71 changes: 59 additions & 12 deletions custom_components/myhome/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,17 +124,55 @@
]


def _call_get_gateway_profile(
model: str | None, firmware: str | None = None, *, lookup: Any = None
) -> Any:
"""Call ``get_gateway_profile`` with optional firmware, tolerating 1-arg mocks."""
fn = lookup if lookup is not None else get_gateway_profile
if firmware:
try:
return fn(model, firmware)
except TypeError: # pragma: no cover - fallback for 1-arg mocks or older OWNd
return fn(model)
return fn(model)


def _profile_for_model(
model: str | None, firmware: str | None = None, *, lookup: Any = None
) -> Any:
"""Resolve ``model`` to an OWNd profile, mapping SSDP spellings like ``H/L4684``."""
profile = _call_get_gateway_profile(model, firmware, lookup=lookup)
if isinstance(profile, GenericGatewayProfile) and model:
mapped = _MODEL_SPELLINGS.get(_alnum(model))
if mapped is not None:
return _call_get_gateway_profile(mapped, firmware, lookup=lookup)
return profile


def _firmware_at_least(firmware: str | None, minimum: tuple[int, int, int]) -> bool:
"""Return whether ``firmware`` parses to at least ``minimum`` (V, R, B)."""
if not firmware:
return False
parts = [int(m) for m in re.findall(r"\d+", str(firmware))]
if not parts:
return False
while len(parts) < 3:
parts.append(0)
return tuple(parts[:3]) >= minimum


def command_session_limit(model: str | None) -> int | None:
"""Return how many command sessions a known gateway model accepts at once."""
profile = get_gateway_profile(model)
if isinstance(profile, GenericGatewayProfile):
profile = _profile_for_model(model)
max_sessions = getattr(profile, "max_command_sessions", None)
if isinstance(profile, GenericGatewayProfile) or max_sessions is None:
return None
return int(profile.max_command_sessions)
return int(max_sessions)


def command_session_default(model: str | None) -> int:
"""Return how many command sessions a new entry should start with."""
profile = get_gateway_profile(model)
profile = _profile_for_model(model)
default = int(getattr(profile, "default_command_sessions", 1))
limit = command_session_limit(model)
return max(1, min(default, limit) if limit is not None else 1)
Expand Down Expand Up @@ -229,9 +267,9 @@ def command_session_default(model: str | None) -> int:
key=len,
reverse=True,
)
# SSDP announces the H4684 as "HL4684" (manifest.json), which OWNd does not alias to its H4684
# profile yet (it knows h4684, l4684, n4684, nt4684, lgrh4684; #794 maps the same spelling), and
# "HC4684" is the other prefixed spelling of the same product: keyed on the oracle's product name.
# SSDP announces the H4684 as "H/L4684" (issue #466) or "HL4684" (manifest.json), which OWNd<=2.0.0b11
# does not alias to its H4684 profile yet, and "HC4684" is the other prefixed spelling of the same product.
# TODO(OWNd>2.0.0b11): remove shim once manifest.json pins an OWNd release containing OWNd#147.
_MODEL_SPELLINGS = {"HL4684": "H4684", "HC4684": "H4684"}


Expand Down Expand Up @@ -313,6 +351,8 @@ def __init__(
self.config_entry = config_entry
self.generate_events = generate_events
self.gateway = OWNGateway(build_info)
if isinstance(self.gateway.profile, GenericGatewayProfile):
self.gateway.profile = _profile_for_model(self.gateway.model_name, self.gateway.firmware)
self.is_connected = False
self._available = False
self._failover_active = False
Expand Down Expand Up @@ -460,17 +500,21 @@ def firmware(self) -> str | None:
@property
def supports_zone_status(self) -> bool:
"""Whether the gateway supports point-to-point zone status queries (*#4*Z##)."""
# The configured model string and the model OWNd resolves it to, so the spellings OWNd
# aliases (L4684, N4684, NT4684, LGR4684 and the MyHOME_Suite item numbers such as 003598
# for the F454) are judged like the model they stand for.
names = (self.model.upper(), str(get_gateway_profile(self.model).model_name).upper())
# Check the configured model string, its alphanumeric form (so "BTicino H/L4684" matches
# "HL4684"), and the profile model _profile_for_model resolves it to (L4684, NT4684,
# H/L4684, HL4684, HC4684, and MyHOME_Suite catalogue codes such as 003598).
names = (
self.model.upper(),
_alnum(self.model),
str(_profile_for_model(self.model).model_name).upper(),
)
return not any(
k in norm
for norm in names
for k in (
"F450",
"F455",
"HL4684", # the SSDP spelling of the H4684 (manifest.json); not an OWNd alias yet
"HL4684", # TODO(OWNd>2.0.0b11): remove once manifest.json pins OWNd containing OWNd#147
"F454",
"MH200",
"MH201",
Expand Down Expand Up @@ -766,6 +810,9 @@ def profile_supports_who(self, who: int) -> bool:

def _profile_supports_who(self, who: int) -> bool:
"""Return whether the gateway profile advertises a WHO subsystem (True when unknown)."""
# TODO(OWNd>2.0.0b11): remove shim once manifest.json pins an OWNd release containing OWNd#147.
if who == 22 and self._model_key == "H4684" and _firmware_at_least(self.firmware, (6, 0, 0)):
return True
profile = getattr(self.gateway, "profile", None)
supports = getattr(profile, "supports_who", None)
if not callable(supports):
Expand Down
10 changes: 4 additions & 6 deletions custom_components/myhome/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@


def _normalized(model: str | None) -> str:
"""Compare model names without case, spaces, dashes or underscores."""
return str(model or "").strip().upper().replace(" ", "").replace("-", "").replace("_", "")
"""Compare model names without case, spaces, dashes, slashes or underscores."""
return str(model or "").strip().upper().replace(" ", "").replace("-", "").replace("_", "").replace("/", "")


def _same_product_names() -> dict[str, frozenset[str]]:
Expand Down Expand Up @@ -403,6 +403,7 @@ def _handle_gateway_diagnostics(self, message: Any) -> None:
fw,
)
self.gateway.firmware = fw
self.gateway.profile = _gw._profile_for_model(self.gateway.model_name, fw)
if self.config_entry is not None:
new_data = dict(self.config_entry.data)
if new_data.get(CONF_FIRMWARE) != fw:
Expand Down Expand Up @@ -534,10 +535,7 @@ def _apply_model(self, model: str) -> None:
from .topology import entry_firmware

fw = entry_firmware(self.config_entry)
try:
self.gateway.profile = _gw.get_gateway_profile(model, fw)
except TypeError: # pragma: no cover - fallback for OWNd without firmware argument
self.gateway.profile = _gw.get_gateway_profile(model)
self.gateway.profile = _gw._profile_for_model(model, fw)
self.gateway._log_id = f"[{model} gateway - {self.gateway.host}]"
self._trim_sending_workers(model)
new_data = dict(self.config_entry.data)
Expand Down
5 changes: 5 additions & 0 deletions custom_components/myhome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
"OWNd==2.0.0b11"
],
"ssdp": [
{
"st": "upnp:rootdevice",
"manufacturer": "BTicino S.p.A.",
"modelName": "H/L4684"
},
{
"st": "upnp:rootdevice",
"manufacturer": "BTicino S.p.A.",
Expand Down
11 changes: 6 additions & 5 deletions custom_components/myhome/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,11 +246,12 @@ async def handle_sweep_bus(call: ServiceCall) -> None:
for gw_mac, handler in target_gateways.items():
_LOGGER.info("Executing diagnostic bus sweep on gateway %s (force=%s)", gw_mac, force)
gateway_queries = [
"*#13**0##", # Gateway real-time clock
"*#13**15##", # Gateway device model
"*#13**16##", # Gateway firmware version
"*#13**23##", # Gateway kernel version (modern Linux gateways; NACK ignored on older)
"*#13**24##", # Gateway distribution version (modern Linux gateways; NACK ignored on older)
"*#13**0##", # Gateway real-time clock
"*#13**15##", # Gateway device model
"*#13**16##", # Gateway firmware version
"*#13**23##", # Gateway kernel version (modern Linux gateways; NACK ignored on older)
"*#13**24##", # Gateway distribution version (modern Linux gateways; NACK ignored on older)
"*#1013*0*1##", # Gateway diagnostic OBJECT_MODEL
]
general_queries = ((1, "*#1*0##"), (2, "*#2*0##"), (4, "*#4*0##"), (5, "*#5*0##"), (16, "*#16*0*5##"))

Expand Down
14 changes: 9 additions & 5 deletions custom_components/myhome/topology.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,20 @@ def gateway_supported_whos(model: str | None, firmware: str | None = None) -> se
whos: set[int] = set()
norm = (model or "").strip().upper()
try:
from OWNd.profiles import get_gateway_profile
import OWNd.profiles as _ownd_profiles

try:
profile = get_gateway_profile(model or "", firmware)
except TypeError: # pragma: no cover - fallback for OWNd without firmware argument
profile = get_gateway_profile(model or "")
from .gateway import _firmware_at_least, _profile_for_model

profile = _profile_for_model(
model or "", firmware, lookup=_ownd_profiles.get_gateway_profile
)
supports = getattr(profile, "supports_who", None)
for w in getattr(profile, "supported_who", None) or ():
if not callable(supports) or supports(int(w)):
whos.add(int(w))
# TODO(OWNd>2.0.0b11): remove shim once manifest.json pins an OWNd release containing OWNd#147.
if getattr(profile, "model_name", None) == "H4684" and _firmware_at_least(firmware, (6, 0, 0)):
whos.add(22)
except Exception:
pass
# Model-specific hardware capability constraints:
Expand Down
Loading
Loading