Skip to content
Closed
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
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 @@ -19,7 +19,7 @@ See the cover docs for the details and the known limits.
#### ⚠️ Other behaviour changes

- **`myhome.send_message` and `myhome.sync_time` reject unknown keys.** A misspelt key such as `gw_mac` or a missing `message` raises a validation error instead of sending the frame to the default gateway; both services get text selectors in the visual editor (#189).
- **Climate zones show the setpoint in force (dimension 12).** When the thermostat regulates to another setpoint than the commanded one (dimension 14; 23.5 in force while 21.0 commanded on an F461), the entity and `local_target_temperature` now show the one in force instead of the commanded value plus the knob offset. Known limit: once a zone has reported dimension 12, a dimension 14 that is not the echo of a Home Assistant write does not move the slider until the next dimension 12 report or poll (see Known Limitations).
- **Climate zones show the setpoint in force (dimension 12).** When the thermostat regulates to another setpoint than the commanded one (dimension 14; 23.5 in force while 21.0 commanded on an F461), the entity and `local_target_temperature` now show the one in force instead of the commanded value plus the knob offset. This applies to gateways that answer `*#4*Z##` (F461, MyHomeServer1), where the poll refreshes dimension 12; a central-unit plant behind an SCS gateway (MH200N, F453AV, ...) updates a zone with dimension 14 alone and keeps showing the commanded setpoint plus the knob offset, as in b15 (see Known Limitations).
- **`myhome.sync_time` refuses time zones with a half-hour offset** (e.g. `Asia/Kolkata`, UTC+5:30) with a translated error. The WHO 13 time-zone field carries whole hours only; b15 (on OWNd 2.0.0b10) set the gateway clock with a truncated offset without saying so (#764).
- **`advanced: true` on a standard F411 shutter now logs a startup warning.** Those actuators have no position reporting, so the position stayed at 50 % and calibration was refused; the error now says to remove `advanced: true` (#761, #762).

Expand Down
10 changes: 9 additions & 1 deletion custom_components/myhome/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ def __init__(
self._local_target_temperature: float | None = None
# True while _local_target_temperature is the zone's own dimension 12 report (the
# setpoint in force, offset included): dimension 14 and 13 frames then leave it alone.
# Only on a gateway that answers *#4*Z##, where the poll refreshes dimension 12: a
# central-unit plant (MH200N, F453AV) updates a zone with dimension 14 alone.
self._local_target_reported = False
self._nominal_before_dim12: tuple[float | None, float] | None = None
self._poll_health = PollHealth()
Expand Down Expand Up @@ -962,7 +964,13 @@ def handle_event(self, message: OWNHeatingEvent) -> None:
message.human_readable_log,
)
self._local_target_temperature = message.local_set_temperature
self._local_target_reported = self._local_target_temperature is not None
# Hold the display on this report only where *#4*Z## can refresh it. A central-unit
# plant reports dimension 12 once and then dimension 14 (and 13) alone (MH200N zone 2:
# 12 = 10.5, then 14 = 10.0 twice; F453AV Daily Holiday: 23 dimension 14 frames, no 12);
# with no poll to clear it the zone would stay on the stale value.
self._local_target_reported = (
self._local_target_temperature is not None and self._gateway_supports_zone_status
)
is_protection_or_off = self._dimension_3_is_protection(message)
if not is_protection_or_off:
self._remember_nominal()
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/climate.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ MyHOME climate entities support standard Home Assistant climate controls:
- `auto`: Following scheduled setpoint or central program.
- `off`: System frost-protection or thermal shutdown.
* **Target Temperature**: Adjustable setpoint slider in 0.5 °C increments.
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.
On a gateway that answers `*#4*Z##` (F461, MyHomeServer1) 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 a central-unit plant behind an SCS gateway (MH200N, F453AV, ...), which is never polled per zone and updates a zone with dimension 14 alone, the slider shows the commanded setpoint plus the knob offset. 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).

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/known_limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Things the integration does not do, or does with a caveat, and the reason. Where
| :--- | :--- | :--- |
| **Weekly programs, holiday and scenario modes are not exposed.** | Only manual set-point, auto, off and the seasonal modes are modelled on the climate entity. | `myhome.send_message` with the central-unit mode frame (`*4*<mode>*#0##`). |
| **4-zone central unit 4695 (`#0#1`) is verified on synthetic frames only.** | No community trace from a 4695 plant yet. | Please attach a trace to RFC #248 if you own one. |
| **A commanded setpoint (dimension 14) that arrives without a following dimension 12 does not update the slider once the zone has reported a setpoint in force.** | The slider follows dimension 12 (the setpoint the thermostat regulates to, knob offset included) as soon as the zone has reported it; a dimension 14 that is not the echo of a write from Home Assistant is kept as the commanded value only. Both captured plants (F461 2.0.11 with a KW4691, MyHomeServer1 3.87.13 with 36 zones) emit dimension 12 after every write and after every local change, so the slider stays current there; a gateway that echoed only dimension 14 for an external change would leave the slider on the previous value until the next dimension 12 report or poll. | Reload the entity (`homeassistant.update_entity`) to poll `*#4*Z##`; please attach a trace if you see this. |
| **The setpoint in force (dimension 12) is only held on gateways that answer `*#4*Z##`.** | On an F461 or MyHomeServer1 the slider follows dimension 12 (the setpoint the thermostat regulates to, knob offset included) and a dimension 14 that is not the echo of a write from Home Assistant is kept as the commanded value only, until the next dimension 12 report or poll; both captured plants report dimension 12 after every write, every local change and every poll. A central-unit plant behind an SCS gateway (F454, F455, MH200N, MH202, F453AV, ...) is never polled per zone (#674) and updates a zone with dimension 14 and 13 alone (MH200N zone 2, F453AV Daily Holiday captures), so there the slider shows the commanded setpoint plus the knob offset, as before 2.0.0b16; a thermostat that regulates to another setpoint than the commanded one is not shown as such on those gateways. | Nothing needed on the central-unit plants. On an F461 or MyHomeServer1, `homeassistant.update_entity` polls `*#4*Z##`; please attach a trace if a dimension 14 arrives there without dimension 12. |
| **Probes (`WHERE ≥ 100`) are read-only and push-driven.** | They NACK the explicit poll (`*#4*ZPP*15##`) and push readings on their own schedule. | Nothing needed; a probe that goes silent is polled again after 5 min. |

## Burglar alarm (WHO 5)
Expand Down
217 changes: 217 additions & 0 deletions tests/test_climate_setpoint_latch_central_plants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""The setpoint-in-force hold (dimension 12) applies only where ``*#4*Z##`` can refresh it.

A gateway that answers the zone status request (F461, MyHomeServer1) reports dimension 12
and 14 together on every poll and after every write, so the hold on dimension 12 is refreshed
by the next poll. A central-unit plant behind an SCS gateway (MH200N, F453AV) is never polled
per zone (#674) and updates a zone with dimension 14 (and 13) alone, so there the commanded
setpoint plus the knob offset is shown, as before #791. Replays of the captured frames:

* MH200N (``tests/fixtures/traces/issue_466/myhome_sweep_MH200N_all_2026-09-25T14-40-10.json``,
no ``*#4*2##`` sent): zone 2 reports ``*#4*2*12*0105*3##`` spontaneously, then 9.7 s and
20.6 s later ``*#4*2*14*0100*3##`` + ``*#4*2*13*00##`` + ``*4*110*#2##`` with no dimension 12.
* F453AV (``tests/fixtures/traces/issue_619/``, passive captures): the 10:29:57 sweep reports
zone 14 with ``*#4*14*12*0080*3##`` then ``*#4*14*14*0100*3##``; Daily Holiday 33 minutes
later (``11-02-55.Holiday.ON``, ``*4*115#1101*#0##``) emits 23 dimension 14 frames (22 of them
``*#4*Z*14*0100*3##``) and no dimension 12.
* F461 2.0.11 (``tests/fixtures/traces/f461_live_2026-10-23/``): ``*#4*2##`` is answered with
dimension 12 (23.5) then dimension 14 (21.0); the write ``*#4*2*#14*0235*3##`` is echoed with
dimension 12 alone and dimension 14 catches up in the next poll reply, after dimension 12.
"""
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock

import pytest
from homeassistant.components.climate import HVACMode
from OWNd.message import OWNHeatingEvent

from custom_components.myhome.climate import MyHOMEClimate

TRACES = Path(__file__).resolve().parent / "fixtures" / "traces"
MH200N_SWEEP = TRACES / "issue_466" / "myhome_sweep_MH200N_all_2026-09-25T14-40-10.json"
F453AV_SWEEP = TRACES / "issue_619" / "myhome_trace_F453AV_who4_2026-10-11T10-29-57.json"
F453AV_HOLIDAY_ON = TRACES / "issue_619" / "myhome_trace_F453AV_who4_2026-10-11T11-02-55.Holiday.ON.json"
F461_LIVE = TRACES / "f461_live_2026-10-23" / "myhome_trace_F461_live_2026-10-23.json"


def _frames(path: Path) -> list[dict]:
return json.loads(path.read_text(encoding="utf-8"))["frames"]


def _who_where(raw: str) -> tuple[str, str]:
"""WHO and WHERE of a frame: ``*WHO*WHAT*WHERE##`` or ``*#WHO*WHERE*DIM...##``."""
parts = raw.rstrip("#").split("*")
return (parts[1][1:], parts[2]) if raw.startswith("*#") else (parts[1], parts[3])


def _zone_frames(frames: list[dict], where: str, run: str | None = None) -> list[str]:
"""The received WHO 4 frames of one zone (``Z`` and the sub-central form ``#Z``)."""
return [
frame["raw"]
for frame in frames
if frame["direction"] == "rx"
and _who_where(frame["raw"]) in (("4", where), ("4", f"#{where}"))
and (run is None or frame.get("run") == run)
]


def _zone(hass, where: str, zone_status: bool) -> MyHOMEClimate:
gateway = MagicMock()
gateway.mac = "00:03:50:00:04:66"
gateway.send = AsyncMock()
gateway.send_status_request = AsyncMock()
gateway.has_central_unit = False
gateway.supports_zone_status = zone_status
zone = MyHOMEClimate(
hass=hass, name=f"Zone {where}", device_id=f"4-{where}", who="4", where=where,
heating=True, cooling=False, fan=False, standalone=None, central=False,
manufacturer="BTicino", model="Heating Zone", gateway=gateway,
)
zone.hass = hass
zone.entity_id = f"climate.zone_{where}"
zone.async_write_ha_state = MagicMock()
return zone


def _feed(zone: MyHOMEClimate, frames: list[str]) -> None:
for raw in frames:
zone.handle_event(OWNHeatingEvent(raw))


# ------------------------------------------------------------------ central-unit plants (not pollable)

async def test_mh200n_zone_2_follows_the_dimension_14_update_reported_without_dimension_12(hass):
"""Frames 159-191 of the sweep: 12 = 10.5 at 14:39:27, then 14 = 10.0 at :37 and :48, no dimension 12."""
frames = _frames(MH200N_SWEEP)
assert not any(f["direction"] == "tx" and f["raw"] == "*#4*2##" for f in frames)
raws = _zone_frames(frames, "2")
assert raws.count("*#4*2*12*0105*3##") == 1 and raws.count("*#4*2*14*0100*3##") == 2
first_12 = raws.index("*#4*2*12*0105*3##")
assert not any(raw.startswith("*#4*2*12*") for raw in raws[first_12 + 1 :])

zone = _zone(hass, "2", zone_status=False)
assert zone._gateway_supports_zone_status is False
_feed(zone, raws[: first_12 + 1])
assert zone.hvac_mode == HVACMode.HEAT
assert zone.target_temperature == 10.5 # the spontaneous report, until the zone says more
_feed(zone, raws[first_12 + 1 :])
assert zone.target_temperature == 10.0 # the later dimension 14 is the zone's setpoint now
assert zone.extra_state_attributes["local_target_temperature"] == 10.0
assert zone._target_temperature == 10.0


async def test_f453av_zone_14_holiday_on_updates_the_zone_without_dimension_12(hass):
"""Sweep: 12 = 8.0 then 14 = 10.0 for zone 14; Daily Holiday: ``*#4*14*14*0100*3##``, no dimension 12."""
holiday = _frames(F453AV_HOLIDAY_ON)
assert sum(f["raw"] == "*4*115#1101*#0##" for f in holiday) == 1
holiday_who4 = [f["raw"] for f in holiday if _who_where(f["raw"])[0] == "4"]
assert sum(raw.startswith("*#4*") and raw.split("*")[3] == "14" for raw in holiday_who4) == 23
assert sum("*14*0100*3##" in raw for raw in holiday_who4) == 22 # zone 17 reports 26.0
assert not any(raw.startswith("*#4*") and raw.split("*")[3] == "12" for raw in holiday_who4)

zone = _zone(hass, "14", zone_status=False)
sweep = _zone_frames(_frames(F453AV_SWEEP), "14")
assert sweep.index("*#4*14*12*0080*3##") < sweep.index("*#4*14*14*0100*3##")
_feed(zone, sweep[: sweep.index("*#4*14*12*0080*3##") + 1])
assert zone.target_temperature == 8.0
_feed(zone, sweep[sweep.index("*#4*14*12*0080*3##") + 1 :])
assert zone.hvac_mode == HVACMode.HEAT
assert zone.target_temperature == 10.0 # the central unit's setpoint for the zone, as before #791

on_holiday = _zone_frames(holiday, "14")
assert on_holiday == ["*4*111*#14##", "*4*111*#14##", "*#4*14*0*0280##", "*#4*14*14*0100*3##"]
_feed(zone, on_holiday)
assert zone.target_temperature == 10.0
assert zone.extra_state_attributes["local_target_temperature"] == 10.0


async def test_a_knob_turn_reported_by_dimension_13_alone_moves_the_slider(hass):
"""MH200N dial turns emit ``*#4*Z*13*OO##`` only (frames 106-130 of the sweep, zone 4)."""
zone = _zone(hass, "4", zone_status=False)
_feed(zone, ["*4*110*#4##", "*#4*4*12*0100*3##", "*#4*4*14*0100*3##", "*#4*4*13*00##"])
assert zone.target_temperature == 10.0
_feed(zone, ["*#4*4*13*03##"])
assert zone.target_temperature == 13.0
assert zone.extra_state_attributes["local_offset"] == 3
_feed(zone, ["*#4*4*13*13##"]) # -3
assert zone.target_temperature == 7.0


async def test_an_scs_gateway_shows_the_commanded_setpoint_plus_offset_as_before(hass):
"""The F461 reply order replayed on a gateway that cannot be polled per zone: 21.0 + offset (status quo)."""
zone = _zone(hass, "2", zone_status=False)
_feed(zone, ["*#4*2*0*0239##", "*#4*2*12*0235*3##", "*4*1*2##", "*#4*2*14*0210*3##", "*#4*2*13*00##"])
assert zone.target_temperature == 21.0
assert zone._local_target_reported is False
_feed(zone, ["*#4*2*12*0235*3##"])
assert zone.target_temperature == 23.5 # the last report is shown, nothing is held
_feed(zone, ["*#4*2*13*02##"])
assert zone.target_temperature == 25.5 # nominal re-derived from the report (23.5) plus the offset


# ------------------------------------------------------------------ pollable gateways keep the hold

async def test_f461_live_poll_replies_show_the_setpoint_in_force(hass):
"""run1: ``*#4*2##`` → 0, 12 = 23.5, season, 14 = 21.0, 13; a second poll 27 s later, the same."""
frames = _frames(F461_LIVE)
run1 = [
(f["timestamp"], f["raw"])
for f in frames
if f.get("run") == "run1_oracle_replay_readonly" and f["raw"] in _zone_frames(frames, "2", "run1_oracle_replay_readonly")
]
polls = [raw for t, raw in run1 if 325.0 <= t <= 353.0]
assert polls == [
"*#4*2*0*0239##", "*#4*2*12*0235*3##", "*4*1*2##", "*#4*2*14*0210*3##", "*#4*2*13*00##",
"*#4*2*60*39##",
"*#4*2*0*0239##", "*#4*2*12*0235*3##", "*4*1*2##", "*#4*2*14*0210*3##", "*#4*2*13*00##",
]
zone = _zone(hass, "2", zone_status=True)
_feed(zone, polls[:5])
assert zone.target_temperature == 23.5
assert zone._target_temperature == 21.0
assert zone._local_target_reported is True
_feed(zone, polls[5:])
assert zone.target_temperature == 23.5
assert zone.hvac_mode == HVACMode.HEAT


async def test_f461_live_write_echo_then_poll_keeps_the_written_setpoint(hass):
"""run3: the write is echoed by dimension 12 alone; dimension 14 catches up in the next poll, after 12."""
frames = _frames(F461_LIVE)
run3 = _zone_frames(frames, "2", run="run3_climate_setpoint_restore")
assert run3 == [
"*#4*2*12*0235*3##", "*4*1*2##", "*#4*2*0*0239##",
"*#4*2*0*0239##", "*#4*2*12*0235*3##", "*4*1*2##", "*#4*2*14*0235*3##", "*#4*2*13*00##",
]
zone = _zone(hass, "2", zone_status=True)
_feed(zone, _zone_frames(frames, "2", run="run1_oracle_replay_readonly")[:5])
assert zone.target_temperature == 23.5
await zone.async_set_temperature(temperature=23.5)
assert str(zone._gateway_handler.send.await_args.args[0]) == "*#4*2*#14*0235*3##"
_feed(zone, run3)
assert zone.target_temperature == 23.5
assert zone._target_temperature == 23.5
assert zone._local_target_reported is True


async def test_antifreeze_split_seen_live_on_2026_10_24(hass):
"""``*4*102*2##`` → 12 = 7.0 while 14 = 23.5 stays; the poll reply shows 7.0; the restore shows 23.5."""
zone = _zone(hass, "2", zone_status=True)
_feed(zone, ["*#4*2*0*0241##", "*#4*2*12*0235*3##", "*4*1*2##", "*#4*2*14*0235*3##", "*#4*2*13*00##"])
_feed(zone, ["*#4*2*12*0070*3##", "*4*102*2##"])
assert zone.hvac_mode == HVACMode.OFF
assert zone.target_temperature == 7.0
_feed(zone, ["*#4*2*0*0241##", "*#4*2*12*0070*3##", "*4*102*2##", "*#4*2*14*0235*3##", "*#4*2*13*00##"])
assert zone.target_temperature == 7.0
assert zone._target_temperature == 23.5
_feed(zone, ["*4*1*2##", "*#4*2*12*0235*3##", "*#4*2*14*0235*3##"])
assert zone.hvac_mode == HVACMode.HEAT
assert zone.target_temperature == 23.5


@pytest.mark.parametrize("zone_status", [True, False])
async def test_a_poll_or_report_with_dimension_12_last_shows_it_on_both_kinds_of_gateway(hass, zone_status):
zone = _zone(hass, "2", zone_status=zone_status)
_feed(zone, ["*4*1*2##", "*#4*2*14*0210*3##", "*#4*2*13*00##", "*#4*2*12*0235*3##"])
assert zone.target_temperature == 23.5
assert zone._local_target_reported is zone_status
Loading