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
104 changes: 103 additions & 1 deletion custom_components/myhome/climate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import inspect
import time
from typing import Any, cast

Expand Down Expand Up @@ -77,7 +78,7 @@
default_known_keys,
is_broadcast_where,
)
from .gateway import _ZONE_WHERE, MyHOMEGatewayHandler
from .gateway import _ZONE_WHERE, SILENT_ZONE_GRACE, MyHOMEGatewayHandler
from .myhome_device import MyHOMEEntity
from .poll_health import PollHealth
from .thermo_switch import load_thermo_switch_subentries
Expand All @@ -95,6 +96,8 @@

PLATFORM = Platform.CLIMATE
PARALLEL_UPDATES = 0
# How long one read of the silent-zone poll (#466) waits for its answer before giving up.
PACED_READ_TIMEOUT = 60.0

_FOUR_ZONE_CENTRAL = ("#0#1", "#0#2", "#0#3", "#0#4")
# BTicino 3550 central unit (#0) diagnostic WHAT codes (OpenWebNet Thermoregulation v2.0.0 Table 5):
Expand Down Expand Up @@ -628,6 +631,10 @@ def __init__(
# setpoint in force, offset included): dimension 14 and 13 frames then leave it alone.
self._local_target_reported = False
self._current_temperature_restored = False
# True while the setpoint / the mode are what Home Assistant saved, not what the zone
# said since the restart: the silent-zone poll (#466) does not take them for a report.
self._target_temperature_restored = False
self._hvac_mode_restored = False
self._nominal_before_dim12: tuple[float | None, float] | None = None
self._poll_health = PollHealth()
# BTicino 3550 central unit (#0) diagnostic status flags (WHAT 20..24, 30, 31)
Expand Down Expand Up @@ -801,6 +808,7 @@ async def async_restore_last_state(self, last_state: State | None) -> None:
self._attr_hvac_mode = HVACMode.OFF
except (ValueError, TypeError):
self._attr_hvac_mode = HVACMode.OFF
self._hvac_mode_restored = True
target_temp = last_state.attributes.get("temperature")
if target_temp is not None:
try:
Expand All @@ -819,6 +827,7 @@ async def async_restore_last_state(self, last_state: State | None) -> None:
pass
self._target_temperature = shown - self._local_offset
self._local_target_temperature = shown
self._target_temperature_restored = True
if "program" in last_state.attributes:
try:
self._program = int(last_state.attributes["program"])
Expand Down Expand Up @@ -1160,6 +1169,93 @@ async def async_added_to_hass(self) -> None:
watch = getattr(self._gateway_handler, "watch_zone_actuators", None)
if callable(watch):
self.async_on_remove(watch(self._full_where))
# Whether the gateway wants the silent-zone poll (#466) is decided inside the task,
# once the startup sweep and the grace period are over: the in-band identity (WHO 13 /
# WHO 1013) can still correct the model after the entities were added, as the
# actuator request already allows for (``watch_zone_actuators``). So the task is
# scheduled when the gateway wants the poll now, or while the sweep is still to run
# and the model may still change.
if (
not self._interface_poll_refused
and not is_actuator_where(self._where)
and target_hass is not None
and (
getattr(self._gateway_handler, "silent_zone_poll_needed", False) is True
or getattr(self._gateway_handler, "initial_discovery_pending", False) is True
)
):
Comment thread
GreenGrassBlueOcean marked this conversation as resolved.
Resolved
task = target_hass.async_create_background_task(
self._poll_if_silent(), name=f"myhome_{self.entity_id}_silent_zone_poll"
)

def _cancel_silent_zone_poll() -> None:
task.cancel()

self.async_on_remove(_cancel_silent_zone_poll)

def _silent_zone_reads(self) -> list[str]:
"""The reads of the silent-zone poll (#466) this zone still needs, in the order they go out.

What the zone said since the restart is not asked again; a value Home Assistant restored
is. The temperature (``*0``) comes first because a zone without a probe refuses it, and
that refusal ends the poll. The setpoint (``*14``), then the setpoint in force (``*12``,
the F455 follows its reply with the zone's season frame, which gives the mode), then
actuator 1 (``#1*20``) last: ``hvac_action`` is derived when an actuator report finds the
mode known. The zone is named by its number: a ``#Z`` entry of ``myhome.yaml`` would
otherwise ask through the central unit, and an actuator behind an interface is
``Z#N#4#II``, not ``Z#4#II#N``.
"""
zone = zone_number(self._where)
routing = f"{BUS_ROUTING}{self._interface}" if self._interface is not None else ""
temperature_known = self._attr_native_current_temperature is not None and not self._current_temperature_restored
setpoint_known = self.native_target_temperature is not None and not self._target_temperature_restored
mode_known = self._attr_hvac_mode is not None and not self._hvac_mode_restored
reads: list[str] = []
if not temperature_known:
reads.append(f"*#4*{zone}{routing}*0##")
if not setpoint_known:
reads.append(f"*#4*{zone}{routing}*14##")
if not setpoint_known or not mode_known:
reads.append(f"*#4*{zone}{routing}*12##")
if not self._actuator_states and self._zone_active is None:
reads.append(f"*#4*{zone}#1{routing}*20##")
return reads

async def _poll_if_silent(self, grace: float = SILENT_ZONE_GRACE) -> None:
"""Ask a zone that has not reported by itself since the startup sweep, read by read (#466).

Only on a gateway in ``SILENT_ZONE_POLL`` (the one that serves WHO 4, read once the
in-band identity has had the sweep and the grace period to settle). Nothing goes out for
what the zone reported itself (``_silent_zone_reads``). Each read waits for the previous
one to be answered, so a refused or unanswered read (a zone without a probe) ends it
after one request.
"""
wait = getattr(self._gateway_handler, "wait_for_initial_discovery", None)
if callable(wait):
waiting = wait()
if inspect.isawaitable(waiting):
await waiting
await asyncio.sleep(grace)
if getattr(self._gateway_handler, "silent_zone_poll_needed", False) is not True:
return # not an F455 after all, or its zones are another gateway's to ask
reads = self._silent_zone_reads()
if not reads:
return # the zone reported its temperature, setpoint, mode and an actuator on its own
LOGGER.debug(
"%s %s has not reported since the startup sweep; asking %s", self._gateway_handler.log_id, self._display_name, reads
)
for frame in reads:
try:
written: object = await self._gateway_handler.send_status_request(
cast(OWNCommand, OWNHeatingCommand.parse(frame))
)
except Exception: # a transport error must not end the task silently (#593)
LOGGER.exception("%s %s: `%s` could not be sent", self._gateway_handler.log_id, self._display_name, frame)
return
if isinstance(written, asyncio.Future):
await asyncio.wait([written], timeout=PACED_READ_TIMEOUT)
if not written.done() or written.cancelled():
return

def _refuse_interface_write(self) -> bool:
"""Refuse WHO 4 writes that would strip ``#4#II`` and hit local main-bus ``_where``."""
Expand Down Expand Up @@ -1521,6 +1617,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None:
message.human_readable_log,
)
self._target_temperature = message.set_temperature
self._target_temperature_restored = False
is_protection_or_off = self._dimension_3_is_protection(message)
# Dimension 14 is the commanded setpoint. The thermostat can regulate to another one
# (F461: 14 = 21.0 while 12 = 23.5), so a dimension 12 report stays what is shown.
Expand Down Expand Up @@ -1565,6 +1662,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None:
)
self._local_target_temperature = message.local_set_temperature
self._local_target_reported = self._local_target_temperature is not None
self._target_temperature_restored = False
is_protection_or_off = self._dimension_3_is_protection(message)
if not is_protection_or_off:
self._remember_nominal()
Expand Down Expand Up @@ -1593,6 +1691,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None:
if season_mode in self._attr_hvac_modes:
LOGGER.debug("%s %s", self._gateway_handler.log_id, message.human_readable_log)
self._attr_hvac_mode = season_mode
self._hvac_mode_restored = False
if self._attr_hvac_action == HVACAction.OFF:
self._attr_hvac_action = HVACAction.IDLE
else:
Expand All @@ -1619,6 +1718,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None:
elif message.message_type == MESSAGE_TYPE_MODE:
if self._central:
self._reset_central_status_flags()
self._hvac_mode_restored = False # the zone itself says what it runs in
prev_mode = self._attr_hvac_mode
previous_season = self._season
self._remember_season(message)
Expand Down Expand Up @@ -1685,6 +1785,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None:
elif message.message_type == MESSAGE_TYPE_MODE_TARGET:
if self._central:
self._reset_central_status_flags()
self._hvac_mode_restored = False
prev_mode = self._attr_hvac_mode
self._remember_season(message)
self._program = getattr(message, "program", None)
Expand Down Expand Up @@ -1729,6 +1830,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None:
self._mark_actuators_off()
if message.set_temperature is not None and message.set_temperature >= 0:
self._target_temperature = message.set_temperature
self._target_temperature_restored = False
self._show_nominal()
else:
self._hydrate_nominal_from_local(prev_mode)
Expand Down
71 changes: 69 additions & 2 deletions custom_components/myhome/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,23 @@ def command_session_default(model: str | None) -> int:
# MyHomeServer1 replies from its cache, #649), so their zones keep the zone status poll and are not
# listed here.
ZONE_TEMPERATURE_POLL = frozenset({"F450", "F459", "H4684", "MH200", "MH201"})
# Gateways whose zones still ask nothing on add (the F455: #674, sixteen *#4*Z## polls timing out
# one after another), but where a zone that has not reported its temperature, setpoint, mode and
# an actuator by itself SILENT_ZONE_GRACE seconds after the startup sweep asks for what is
# missing, read by read: its temperature (*#4*Z*0##), and only if that was answered its setpoint
# (*#4*Z*14##), its setpoint in force (*#4*Z*12##, followed by the zone's season frame) and
# actuator 1 (*#4*Z#1*20##). A value Home Assistant restored from before the restart is not a
# report. A physical F455 1.0.76 answers each in ~0.1 s and ACKs *#4*Z## only after ~9 s
# (issue_466/F455_direct_requests_2026-11-06.jsonl); its standalone zone 2 (that entry ran as a
# standby on a shared bus, so it had skipped the startup sweep) reported nothing on its own in
# four minutes (issue_466/F455_full_debug_log_2026-11-06T16-24-46.log). On a zone without a probe
# the same F455 NACKs *#4*Z*0## (and *14##, *12##, *19##) only after ~9 s, with no frame
# (issue_466/F455_direct_requests_zone3_2026-11-06.jsonl): the one refused read that ends the poll
# holds a command session for those ~9 s, once per zone added. *#4*Z#1*20## answers in 0.09 s there.
# The gate is read when the poll runs (the in-band identity may correct the model after the
# entities were added) and through the gateway that serves WHO 4 (``silent_zone_poll_needed``).
SILENT_ZONE_POLL = frozenset({"F455"})
SILENT_ZONE_GRACE = 120.0
# Zones behind an F422 bus interface (WHERE Z#4#II): zero of the twelve emulated gateways in
# own-firmware-oracle (findings/thermo-zone-requests.md) emit $06EC / $06E4 interface-routed SCS
# frames for WHO 4. Gateways either NACK/drop Z#4#II queries, truncate Z#4#II at # and cross-poll
Expand All @@ -236,6 +253,12 @@ def command_session_default(model: str | None) -> int:
# Read from the OWNd profile first (``supports_heating_actuator_status``); this table is the
# fallback until the flag exists there.
ACTUATOR_REQUEST_REFUSED = frozenset({"F453", "F453AV", "H4684", "MH200", "MH200N", "MH202"})
# Gateways that refuse the zone form of that request at once but answer the actuator form
# (``*#4*Z#N*20##``): a physical F455 1.0.76 NACKed *#4*2*20##, *#4*4*20##, *#4*3*20## and
# *#4*0*20## in 2-18 ms and answered *#4*2#1*20## / *#4*4#1*20## / *#4*3#1*20## in ~0.09 s
# (issue_466/F455_direct_requests_2026-11-06.jsonl, F455_direct_requests_zone3_2026-11-06.jsonl).
# A zone's watch is not sent there; a thermo actuator switch (``Z#N``) still asks.
ZONE_ACTUATOR_REQUEST_REFUSED = frozenset({"F455"})
# One request for every zone, by dimension. The live F461 2.0.11 answered ``*#4*#0*20##`` with
# the actuators of each zone (``*#4*2#1*20*0##``, ``*#4*2#2*20*5##`` after 2.1 s).
# The MyHomeServer1 3.87.13 (myhomeserver1_live_2026-10-24, run2_reads_thermo_sound_alarm_lights)
Expand All @@ -251,7 +274,9 @@ def command_session_default(model: str | None) -> int:
# F460, F461 and MyHomeServer1 forwarded it, and the live F461 / MyHomeServer1 answer it.
# Read from the OWNd profile first (``supports_heating_humidity_request``: ``True`` / ``False``
# wins, ``None`` = unverified); this table is the fallback until the flag exists there.
HUMIDITY_REQUEST_REFUSED = frozenset({"F450", "F453", "F453AV", "F454", "H4684", "MH200", "MH200N", "MH202"})
HUMIDITY_REQUEST_REFUSED = frozenset({"F450", "F453", "F453AV", "F454", "F455", "H4684", "MH200", "MH200N", "MH202"})
# The F455 is not in the oracle: a physical F455 1.0.76 NACKed *#4*2*60## in 19 ms with nothing on
# the bus (issue_466/F455_direct_requests_2026-11-06.jsonl, t=68.6).
# Every gateway model the request tables can be keyed on: the twelve images the oracle emulates
# (results/<P>/<V>, refusing and forwarding alike) and the other models OWNd profiles, so a
# forwarding model named with a brand prefix ("BTicino F461") is keyed as itself (``F461``)
Expand All @@ -265,7 +290,8 @@ def command_session_default(model: str | None) -> int:
# The models the tables know, longest first so ``MH200N`` is not read as ``MH200``.
_ZONE_REQUEST_MODELS = sorted(
KNOWN_GATEWAY_MODELS | ACTUATOR_REQUEST_REFUSED | HUMIDITY_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS)
| GENERAL_ZONE_REQUEST_REFUSED | SOUND_STATUS_REQUEST_REFUSED | ZONE_TEMPERATURE_POLL,
| GENERAL_ZONE_REQUEST_REFUSED | SOUND_STATUS_REQUEST_REFUSED | ZONE_TEMPERATURE_POLL | SILENT_ZONE_POLL
| ZONE_ACTUATOR_REQUEST_REFUSED,
key=len,
reverse=True,
)
Expand Down Expand Up @@ -327,6 +353,11 @@ def _zone_request_frame(where: str, dimension: int) -> str:
return f"*#4*{_validate_zone_where(where)}*{dimension}##"


def _is_zone_level_where(where: str) -> bool:
"""A zone as a whole (``Z``, ``#Z``, ``Z#4#II``), not one of its actuators (``Z#N``, ``Z#N#4#II``)."""
return "#" not in where.partition(BUS_ROUTING)[0].lstrip("#")


class MyHOMEGatewayHandler(GatewayTopologyMixin, GatewayIdentityMixin):
"""Manages a single MyHOME Gateway."""

Expand Down Expand Up @@ -663,6 +694,18 @@ async def _send_zone_requests(self) -> None:
"%s Zones %s: %s refuses the actuator request; not asking", self.log_id, wheres, target.model
)
continue
if dimension == 20 and target.zone_actuator_request_refused:
zones = [where for where in wheres if _is_zone_level_where(where)]
if zones:
LOGGER.debug(
"%s Zones %s: %s refuses the zone form of the actuator request; asking only actuators",
self.log_id,
zones,
target.model,
)
wheres = [where for where in wheres if not _is_zone_level_where(where)]
if not wheres:
continue
all_zones = target.all_zone_request(dimension)
if all_zones is not None and any(BUS_ROUTING not in where for where in wheres):
frames.append(all_zones)
Expand Down Expand Up @@ -712,6 +755,30 @@ def sound_status_request_refused(self) -> bool:
return True
return self._model_key in SOUND_STATUS_REQUEST_REFUSED

@property
def zone_actuator_request_refused(self) -> bool:
"""Whether this gateway refuses the zone form of the actuator request (``*#4*Z*20##``) only.

Decided by the gateway the request goes through, like ``humidity_request_refused``.
"""
target = self._who4_target()
if target is not self:
return target.zone_actuator_request_refused
return self._model_key in ZONE_ACTUATOR_REQUEST_REFUSED

@property
def silent_zone_poll_needed(self) -> bool:
"""Whether a zone silent after the startup sweep asks its own state once (``SILENT_ZONE_POLL``).

Decided by the gateway the reads go through (``_who4_target``: a secondary WHO 4 is
delegated to, or the warm standby while this primary is down), like
``humidity_request_refused``. A passive follower (a standby, or a secondary without
WHO 4) has no zones of its own to ask.
"""
if self.is_follower and 4 not in self.delegated_whos:
return False
return self._who4_target()._model_key in SILENT_ZONE_POLL

@property
def zone_temperature_poll_needed(self) -> bool:
"""Whether each zone must ask for its temperature (``*#4*Z*0##``) itself.
Expand Down
Loading
Loading