Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
26d4627
feat(climate): request the zone actuators (dimension 20) on add and a…
Oct 24, 2026
0afbb16
fix(climate): ask dimension 19 once a dimension 20 reply reveals a fa…
Oct 24, 2026
f992e8f
fix(climate): keep dimension 20 actuators apart from the dimension 19…
Oct 24, 2026
afb4393
fix(gateway): refuse invalid zone addresses and keep one bad zone fro…
Oct 24, 2026
2e647e7
fix(gateway): decide the actuator request refusal when it is sent, no…
Oct 24, 2026
c889731
fix(gateway): read the request tables through the profile class and t…
Oct 24, 2026
d206fbb
refactor(gateway): use BUS_ROUTING and coalesce zone requests queued …
Oct 24, 2026
667db95
test(climate): cover the fan speed poll with known sides and no seaso…
Oct 24, 2026
05a2fa1
fix(climate): let the pending dimension 19 request expire instead of …
Oct 24, 2026
cfbdeb0
fix(gateway): validate the zone grammar before counting a watch and c…
Oct 24, 2026
516e3e0
fix(climate): ask the fan coil's side before the poll on add and the …
Oct 24, 2026
5bd28f2
test(climate): keep the startup sweep from timing out under fast bus …
Oct 24, 2026
7b42cc7
fix(gateway): write a routed zone's interface with two digits, as OWN…
Oct 24, 2026
b61bb3e
fix(gateway): skip the paced sender when every zone request was dropp…
Oct 24, 2026
9352638
fix(climate): ask the fan speed when the season moves to the fan coil…
Oct 25, 2026
4a41da6
test(climate): replay the F461 and MyHomeServer1 actuator captures th…
Oct 25, 2026
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
2 changes: 1 addition & 1 deletion .github/release-notes/2.0.0b16.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ See the cover docs for the details and the known limits.
- **Expose Home Assistant to the bus**: a declarative `expose` engine projects HA entities and the clock onto free SCS addresses: virtual WHO 1 / WHO 2 actuators that a wall control can drive (mirroring HA state back to its LEDs), WHO 4 virtual temperature probes, WHO 18 virtual energy meters and the WHO 13 bus clock. Custom actions run as Home Assistant scripts (#750).
- **Covers**: `class:` in `myhome.yaml` now reaches WHO 2 covers (a `curtain` is no longer shown as a shutter; #39); Venetian blind slat tilt as timed slat pulses (`slat_tilt`, `slat_time`) (#508); impulse garage and gate covers on WHO 1 relays with optional safety approval, door-entry strike locks and doorbell events (#667); native broadcast for general covers behind an interface (#679); a cover stop overtakes other queued frames (#694); cover kinematics split into pluggable strategies (#739).
- **Lighting**: 2-digit Area 0 point addresses `01`–`09` (#713); ON dimmers on a fresh install are probed for brightness support (#742); timed turn-on uses the OWNd builders (#659); lights the startup sweep already reported are not polled again (#701); area and group re-syncs only skip once every member has echoed, counting only the members' own echoes in the commanded state (#740, #749).
- **Thermoregulation (WHO 4)**: season frames, a generic setpoint mode and the central unit's season (#657, #725); holiday and vacation program attributes (#689); zone humidity remembered and queried at startup (#712); F461 zones polled, AUTO on standalone zones, fan coils per season, season kept across restarts (#714, #715); `hvac_action` derived from the season when the actuator carries no heating flag (#721); standalone default follows the plant (#698); 10 s command timeouts on the F455 removed (#675); migrated to the `native_*` temperature API (#665).
- **Thermoregulation (WHO 4)**: each zone asks for its actuators (dimension 20) after the startup sweep and after a reconnect, so `hvac_action` is known at start; the new `actuators` attribute lists them (#25, #44); season frames, a generic setpoint mode and the central unit's season (#657, #725); holiday and vacation program attributes (#689); zone humidity remembered and queried at startup (#712); F461 zones polled, AUTO on standalone zones, fan coils per season, season kept across restarts (#714, #715); `hvac_action` derived from the season when the actuator carries no heating flag (#721); standalone default follows the plant (#698); 10 s command timeouts on the F455 removed (#675); migrated to the `native_*` temperature API (#665).
- **Sound system**: tuner frequency, preset and RDS read from WHO 22, with optional per-frequency pictures (#695); sound source commands and wall transport steps (#651); amplifiers no longer woken by volume status reports (#670); one decision engine for the power state (#683); an idle decoder can be claimed from a parked group (#723).
- **Scenarios**: WHO 25 Scenario Plus commands and events (#671); WHO 17 scene events (#656).
- **Gateways**: H4684 / LGRH4684 and MH201 identification (#652, #722); kernel and distribution in the diagnostic sweep (#711); user commands go ahead of status polls (#685); one frame scheduler with per-target ordering (#738); the HA web session is used for discovery (#757).
Expand Down
223 changes: 196 additions & 27 deletions custom_components/myhome/climate.py

Large diffs are not rendered by default.

180 changes: 180 additions & 0 deletions custom_components/myhome/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import asyncio
import collections
import inspect
import re
import time
from collections.abc import Iterable
from typing import Any
Expand All @@ -28,6 +29,7 @@

from .bus_monitor import BusMonitor
from .const import (
BUS_ROUTING,
CONF_DEVICE_TYPE,
CONF_FIRMWARE,
CONF_MANUFACTURER,
Expand Down Expand Up @@ -142,6 +144,10 @@ def command_session_default(model: str | None) -> int:
BUS_QUIET_PERIOD = 0.75
BUS_QUIET_CAP = 15.0
PACED_WRITE_TIMEOUT = 60.0
# How long a zone request waits for others once the startup sweep is over: zones discovered
# from incoming frames arrive over 1-2 s, and each would otherwise start its own all-zone
# sweep (*#4*#0*20##) on an F461.
ZONE_REQUEST_COALESCE = 0.1
DISCOVERY_REQUESTS: tuple[tuple[int, str], ...] = (
(1, "*#1*0##"),
(2, "*#2*0##"),
Expand All @@ -151,6 +157,51 @@ def command_session_default(model: str | None) -> int:
(18, "*#18*51*51##"),
(18, "*#18*51*113##"),
)
# Gateways whose firmware drops the per-zone actuator request (``*#4*Z*20##``) instead of
# forwarding it to the bus: own-firmware-oracle ``results/<P>/<V>/oracle/full/thermo-actuator.tsv``
# (``*#4*1*20##`` "silent" on these six, "out" on F450, F454, F459, F460, F461, MyHomeServer1).
# 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"})
# 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)
# answered it in 0.39 s with 43 actuator frames for its 34 zones, where asking each zone
# through the paced sender takes about 32 s; the emulated 028206 forwards it ("out").
ALL_ZONE_REQUESTS: dict[str, dict[int, str]] = {
"F461": {20: "*#4*#0*20##"},
"MYHOMESERVER1": {20: "*#4*#0*20##"},
}
# The models the two tables know, longest first so ``MH200N`` is not read as ``MH200``.
_ZONE_REQUEST_MODELS = sorted(ACTUATOR_REQUEST_REFUSED | set(ALL_ZONE_REQUESTS), key=len, reverse=True)


# A WHO 4 zone address: ``Z``, ``#Z``, ``Z#N`` (actuator), each optionally behind an
# interface of exactly two digits (``Z#4#01``): the grammar of OWNd#140's builder
# (``OWNHeatingCommand.actuators_status``).
_ZONE_WHERE = re.compile(rf"^#?[0-9]+(?:#[0-9]+)?(?:{re.escape(BUS_ROUTING)}[0-9]{{2}})?$")


def _validate_zone_where(where: str) -> str:
"""The WHO 4 zone address as it goes into ``*#4*WHERE*DIM##``, or ``ValueError``.

A one-digit interface is written with two (``3#4#1`` -> ``3#4#01``), as the frames on
the wire and the configuration validator spell it. Anything else would change the
frame's grammar: ``2*22`` would turn a dimension 20 request into ``*#4*2*22*20##``, a
dimension 22 write; ``2##`` ends the frame early. The builder of OWNd#140
(``OWNHeatingCommand.actuators_status``) accepts and rejects the same addresses.
"""
zone, routing, interface = (where or "").partition(BUS_ROUTING)
if routing and len(interface) == 1 and interface.isdigit():
where = f"{zone}{routing}{interface.zfill(2)}"
if not _ZONE_WHERE.match(where or ""):
raise ValueError(f"Invalid zone address: {where!r}")
return where


def _zone_request_frame(where: str, dimension: int) -> str:
"""The dimension request of one zone (``*#4*WHERE*DIM##``), validated like a builder."""
return f"*#4*{_validate_zone_where(where)}*{dimension}##"


class MyHOMEGatewayHandler(GatewayTopologyMixin, GatewayIdentityMixin):
Expand Down Expand Up @@ -229,6 +280,11 @@ def __init__(
self._sweep_idle: asyncio.Event = asyncio.Event()
self._sweep_idle.set()
self._last_event_frame_at: float = 0.0
# Heating zones whose actuators are asked after the startup sweep and every reconnect,
# and the (zone, dimension) requests waiting for the next paced run.
self._actuator_zones: dict[str, int] = {}
self._zone_requests: set[tuple[str, int]] = set()
self._zone_request_task: asyncio.Task[None] | None = None

@property
def send_buffer(self) -> FrameScheduler | asyncio.Queue[Any]:
Expand Down Expand Up @@ -344,6 +400,123 @@ def supports_zone_status(self) -> bool:
)
)

@property
def _model_key(self) -> str:
"""The gateway model the request tables are keyed on (``F461``, ``MYHOMESERVER1``).

A known OWNd profile names the model; a ``GenericGatewayProfile`` (also what
``get_gateway_profile("BTicino F461")`` returns) falls back to the configured name,
in which a known model is looked up as a substring (``supports_zone_status`` style).
"""
profile = getattr(self.gateway, "profile", None)
if profile is not None and not isinstance(profile, GenericGatewayProfile):
name = getattr(profile, "model_name", None) or self.model
else:
name = self.model
normalized = "".join(character for character in str(name).upper() if character.isalnum())
return next((model for model in _ZONE_REQUEST_MODELS if model in normalized), normalized)

def _who4_target(self) -> MyHOMEGatewayHandler:
"""The gateway a WHO 4 status request goes through, the way ``send_status_request`` routes it.

On a shared bus WHO 4 may be delegated to a secondary gateway, or served by the warm
standby while this primary is down: that gateway's refusal and all-zone tables apply.
"""
probe = OWNCommand.parse("*#4*#0*20##")
if probe is None: # pragma: no cover - a fixed, well-formed frame
return self
return self._delegated_target(probe) or self._failover_target(probe) or self

@property
def actuator_request_refused(self) -> bool:
"""Whether this gateway drops the zone actuator request (``*#4*Z*20##``).

The OWNd profile flag wins when it exists; otherwise the oracle-derived table.
"""
supported = getattr(getattr(self.gateway, "profile", None), "supports_heating_actuator_status", None)
if isinstance(supported, bool):
return not supported
return self._model_key in ACTUATOR_REQUEST_REFUSED

def all_zone_request(self, dimension: int) -> str | None:
"""The single request this gateway answers with every zone's ``dimension``, if any."""
return ALL_ZONE_REQUESTS.get(self._model_key, {}).get(dimension)

@callback
def watch_zone_actuators(self, where: str) -> CALLBACK_TYPE:
"""Ask for a heating zone's actuators (dimension 20) now and after every reconnect.

Returns the callback that stops it. The zone is always recorded: whether the gateway
refuses the request is decided when it is sent, once the in-band identity (WHO 13 /
WHO 1013) has settled, so a model corrected after the entities were added is honoured.
"""
where = _validate_zone_where(where) # before the zone is counted: no phantom watch
self._actuator_zones[where] = self._actuator_zones.get(where, 0) + 1
self.request_zone_dimension(where, 20)

@callback
def _unwatch() -> None:
count = self._actuator_zones.get(where, 0) - 1
if count > 0:
self._actuator_zones[where] = count
else:
self._actuator_zones.pop(where, None)

return _unwatch

@callback
def request_zone_dimension(self, where: str, dimension: int) -> None:
"""Ask a heating zone for one dimension once the startup sweep is over.

Zones asking together share one paced run; where the gateway answers one request
for every zone (``all_zone_request``), that frame replaces the local zones' own.
"""
if dimension == 22:
# *#4*Z*22## kills the F461 thermostat handler and reboots the gateway.
raise ValueError("WHO 4 dimension 22 is never requested")
self._zone_requests.add((_validate_zone_where(where), dimension))
if self._zone_request_task is None or self._zone_request_task.done():
self._zone_request_task = self.hass.async_create_background_task(
self._send_zone_requests(), name=f"myhome_{self.mac}_zone_requests"
)

async def _send_zone_requests(self) -> None:
"""Send the waiting zone requests through the paced sender, until none is left."""
coalesce = self._initial_discovery_done.is_set()
await self.wait_for_initial_discovery()
while self._zone_requests:
# Zones added in the same pass join this run; after the sweep, zones discovered
# from the bus over the next tenth of a second do too instead of each sweeping on
# its own, and so do zones that arrived while the previous batch was being sent.
await asyncio.sleep(ZONE_REQUEST_COALESCE if coalesce else 0)
coalesce = True
pending, self._zone_requests = self._zone_requests, set()
frames: list[str] = []
target = self._who4_target()
for dimension in sorted({dim for _where, dim in pending}):
wheres = sorted(where for where, dim in pending if dim == dimension)
if dimension == 20 and target.actuator_request_refused:
LOGGER.debug(
"%s Zones %s: %s refuses the actuator request; not asking", self.log_id, wheres, target.model
)
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)
wheres = [where for where in wheres if BUS_ROUTING in where] # behind an interface
for where in wheres:
# One zone the builder rejects must not drop the others of the batch.
try:
frames.append(_zone_request_frame(where, dimension))
except ValueError as err:
LOGGER.warning("%s Zone %s: dimension %s not requested: %s", self.log_id, where, dimension, err)
if not frames:
continue # every item skipped (refusing gateway): no empty sweep
try:
await self.send_paced(frames)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: on gateways in ACTUATOR_REQUEST_REFUSED, every startup and reconnect batch leaves frames == [], and await self.send_paced([]) still toggles _begin_sweep() / _end_sweep(). Guarding if not frames: continue avoids the no-op sweep toggle.

except Exception: # a transport error must not end the task silently (#593)
LOGGER.exception("%s Zone requests %s could not be sent", self.log_id, frames)

@property
def profile(self) -> Any:
"""Return gateway profile."""
Expand Down Expand Up @@ -391,6 +564,9 @@ def _on_event_connection_state_change(self, connected: bool) -> None:
sync_fn = getattr(expose_mgr, "async_sync_all", None)
if inspect.iscoroutinefunction(sync_fn):
self.hass.async_create_task(sync_fn())
# Actuators that switched while the session was down sent their frame to nobody.
for where in list(self._actuator_zones):
self.request_zone_dimension(where, 20)
return

self._event_session_ready.clear()
Expand Down Expand Up @@ -589,6 +765,10 @@ async def close_listener(self) -> bool:
if self._unavailable_timer is not None:
self._unavailable_timer()
self._resync_manager.cancel_all()
if self._zone_request_task is not None:
self._zone_request_task.cancel()
self._zone_request_task = None
self._zone_requests.clear()
self._unavailable_timer = None
self.is_connected = False
self._available = False
Expand Down
1 change: 1 addition & 0 deletions docs/configuration/climate.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ MyHOME climate entities support standard Home Assistant climate controls:
The slider shows the setpoint the thermostat regulates to (dimension 12, knob offset included) whenever the zone has reported it; the commanded setpoint (dimension 14) is only shown until then. On an F461 the two can differ (21.0 commanded, 23.5 in force on a live capture), and the attribute `local_target_temperature` carries the same value. When the zone leaves `off`, the value reported while it was off is taken as the protection setpoint (#383), so the commanded setpoint is shown until the zone reports dimension 12 again; after a `set_temperature` call the next report, dimension 12 or 14, is shown.
* **Current Temperature**: Real-time ambient reading reported by the probe.
* **Current Humidity**: Ambient relative humidity percentage for zones equipped with humidity sensors (e.g. Living Now thermostats or humidity probes).
* **HVAC Action and `actuators`**: a zone asks for the state of its actuators (`*#4*<WHERE>*20##`, one `*#4*#0*20##` for every local zone on an F461 or a MyHomeServer1) once the startup sweep is over and again after the event session reconnects, so `hvac_action` (`heating` / `cooling` / `idle`) is known right after start instead of after the first actuator switch. The attribute `actuators` lists each actuator as `on` or `off` (`{"1": "off", "2": "off"}`) and stays while the zone is off (every actuator `off`). F453, F453AV, H4684, MH200, MH200N and MH202 drop the request (firmware oracle), so their zones are not asked and wait for the actuators' own frames; on a shared bus the gateway that serves WHO 4 (delegated secondary or warm standby) decides. Secondary probes (`WHERE >= 100`) are never asked.

### 💧 Humidity Support & Startup Behavior

Expand Down
Loading
Loading