From 4566943bf2f40b1831211276e88d1bd846b2ee30 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 15:34:03 +0300 Subject: [PATCH 1/2] fix(climate): show the setpoint in force (dimension 12), keep dimension 14 as the commanded value The dimension 14 handler recomputed the displayed target from the commanded setpoint plus the knob offset, overwriting a dimension 12 report received in the same poll. On a live F461 2.0.11 the reply to *#4*2## is *#4*2*0*0239##, *#4*2*12*0235*3##, *4*1*2##, *#4*2*14*0210*3##, *#4*2*13*00##: Home Assistant showed 21.0 while the thermostat regulated to 23.5. Dimension 12 follows every write (*#4*2*#14*0220*3## is answered with *#4*2*12*0220*3##). A dimension 12 report now stays what is shown (flag _local_target_reported); dimension 14 is kept as the commanded value and dimension 13 no longer adds the offset on top of a dimension 12 report. _show_nominal() restores the nominal plus offset when the zone leaves OFF or a frame carries a setpoint of its own, so a protection value seen while OFF is still dropped (#383, #454). Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 1 + custom_components/myhome/climate.py | 38 ++++- docs/configuration/climate.md | 1 + tests/test_climate_setpoint_in_force.py | 190 ++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 tests/test_climate_setpoint_in_force.py diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index f1d672824..640c9df77 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -19,6 +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. - **`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). diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 7c4769ec2..f148e3e24 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -380,6 +380,9 @@ def __init__( self._local_offset: float = 0 self._knob_pos: str = "UNKNOWN" 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. + self._local_target_reported = False self._nominal_before_dim12: tuple[float | None, float] | None = None self._poll_health = PollHealth() @@ -815,6 +818,9 @@ async def async_set_temperature(self, **kwargs: Any) -> None: standalone=self._standalone, ) ) + # The write's echo decides what is shown next: dimension 12 where the gateway reports + # the setpoint in force (F461, MyHomeServer1), else the commanded dimension 14. + self._local_target_reported = False def _dimension_3_is_protection(self, message: OWNHeatingEvent) -> bool: """Whether a dimension 12/14 frame may be a protection setpoint rather than the nominal one. @@ -843,6 +849,17 @@ def _restore_nominal_before_protection(self) -> None: if remembered is not None and time.monotonic() - remembered[1] <= _PROTECTION_FRAME_WINDOW: self._target_temperature = remembered[0] + def _show_nominal(self) -> None: + """Show the nominal setpoint plus the knob offset until the zone reports dimension 12 again. + + Used when the zone leaves OFF (the dimension 12 value seen while OFF was the protection + setpoint) or a frame carries a new setpoint of its own. + """ + if self._target_temperature is None: + return + self._local_target_temperature = self._target_temperature + self._local_offset + self._local_target_reported = False + def _apply_zone_state(self, message: OWNHeatingEvent) -> None: """Dimension 7: the zone's operating state, and its setpoint in state 'setpoint'. @@ -873,7 +890,7 @@ def _apply_zone_state(self, message: OWNHeatingEvent) -> None: if self._attr_hvac_mode != HVACMode.OFF and self._target_temperature is not None and ( temperature is not None or prev_mode == HVACMode.OFF ): - self._local_target_temperature = self._target_temperature + self._local_offset + self._show_nominal() @callback def handle_event(self, message: OWNHeatingEvent) -> None: @@ -903,7 +920,9 @@ def handle_event(self, message: OWNHeatingEvent) -> None: ) self._target_temperature = message.set_temperature is_protection_or_off = self._dimension_3_is_protection(message) - if not is_protection_or_off: + # 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. + if not is_protection_or_off and not self._local_target_reported: self._local_target_temperature = ( self._target_temperature + self._local_offset if self._target_temperature is not None @@ -930,7 +949,11 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._knob_pos = "?" else: self._knob_pos = "UNKNOWN" - if self._target_temperature is not None and self._attr_hvac_mode != HVACMode.OFF: + if ( + self._target_temperature is not None + and self._attr_hvac_mode != HVACMode.OFF + and not self._local_target_reported # dimension 12 already includes the offset + ): self._local_target_temperature = self._target_temperature + self._local_offset elif message.message_type == MESSAGE_TYPE_LOCAL_TARGET_TEMPERATURE: LOGGER.debug( @@ -939,6 +962,7 @@ 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 is_protection_or_off = self._dimension_3_is_protection(message) if not is_protection_or_off: self._remember_nominal() @@ -978,7 +1002,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: and self._attr_hvac_mode != HVACMode.OFF and self._target_temperature is not None ): - self._local_target_temperature = self._target_temperature + self._local_offset + self._show_nominal() if self._central and self.hass is not None and self._attr_hvac_mode is not None: async_dispatcher_send( self.hass, @@ -1039,7 +1063,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: and self._attr_hvac_mode != HVACMode.OFF and self._target_temperature is not None ): - self._local_target_temperature = self._target_temperature + self._local_offset + self._show_nominal() if self._central and self.hass is not None and self._attr_hvac_mode is not None: async_dispatcher_send( self.hass, @@ -1090,9 +1114,7 @@ def handle_event(self, message: OWNHeatingEvent) -> None: self._actuator_states.clear() if message.set_temperature is not None and message.set_temperature >= 0: self._target_temperature = message.set_temperature - self._local_target_temperature = ( - self._target_temperature + self._local_offset - ) + self._show_nominal() if self._central and self.hass is not None and self._attr_hvac_mode is not None: async_dispatcher_send( self.hass, diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index b755064e5..54acf5554 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -54,6 +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. * **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). diff --git a/tests/test_climate_setpoint_in_force.py b/tests/test_climate_setpoint_in_force.py new file mode 100644 index 000000000..61e60a792 --- /dev/null +++ b/tests/test_climate_setpoint_in_force.py @@ -0,0 +1,190 @@ +"""The setpoint in force (dimension 12) of a zone on a live F461 plant. + +F461 2.0.11, standalone zone 2 (actuator 1 heating valve, actuator 2 fan coil), reply to +``*#4*2##``: ``*#4*2*0*0239##``, ``*#4*2*12*0235*3##``, ``*4*1*2##``, +``*#4*2*14*0210*3##``, ``*#4*2*13*00##``. Dimension 12 (23.5) is what the thermostat +regulates to; dimension 14 (21.0) is the commanded setpoint. Showing 21.0 hid a 2.5 degree +difference. After a manual write ``*#4*2*#14*0220*3##`` the gateway emits +``*#4*2*12*0220*3##`` and the season frame. +""" +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.components.climate import HVACMode +from OWNd.message import OWNHeatingEvent + +from custom_components.myhome import climate as climate_module +from custom_components.myhome.climate import MyHOMEClimate + +F461_POLL_REPLY = ( + "*#4*2*0*0239##", + "*#4*2*12*0235*3##", + "*4*1*2##", + "*#4*2*14*0210*3##", + "*#4*2*13*00##", +) + + +def _zone(hass, where="2", gateway=None, **kwargs): + if gateway is None: + gateway = MagicMock() + gateway.mac = "00:03:50:00:04:97" + gateway.send = AsyncMock() + gateway.send_status_request = AsyncMock() + gateway.has_central_unit = False + zone = MyHOMEClimate( + hass=hass, name=f"Zone {where}", device_id=f"4-{where}", who="4", where=where, + heating=True, cooling=True, fan=False, standalone=True, central=False, + manufacturer="BTicino", model="Heating Zone", gateway=gateway, **kwargs, + ) + zone.hass = hass + zone.entity_id = f"climate.zone_{where}" + zone.async_write_ha_state = MagicMock() + return zone + + +def _feed(zone, *frames): + for frame in frames: + zone.handle_event(OWNHeatingEvent(frame)) + + +# ------------------------------------------------------------------ dimension 12 vs 14 + +async def test_live_f461_poll_reply_shows_the_setpoint_in_force(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY) + assert zone.current_temperature == 23.9 + assert zone.hvac_mode == HVACMode.HEAT + assert zone.target_temperature == 23.5 + assert zone.extra_state_attributes["local_target_temperature"] == 23.5 + assert zone._target_temperature == 21.0 # the commanded setpoint is kept + + +async def test_dimension_14_before_dimension_12_shows_the_setpoint_in_force(hass): + zone = _zone(hass) + _feed(zone, "*#4*2*0*0239##", "*#4*2*14*0210*3##", "*#4*2*12*0235*3##", "*4*1*2##", "*#4*2*13*00##") + assert zone.target_temperature == 23.5 + + +@pytest.mark.parametrize( + "frames", + [ + ("*4*1*2##", "*#4*2*12*0235*3##", "*#4*2*14*0210*3##", "*#4*2*13*00##"), + ("*4*1*2##", "*#4*2*14*0210*3##", "*#4*2*12*0235*3##", "*#4*2*13*00##"), + ], + ids=["running-12-then-14", "running-14-then-12"], +) +async def test_running_zone_keeps_dimension_12_in_either_order(hass, frames): + zone = _zone(hass) + _feed(zone, *frames) + assert zone.target_temperature == 23.5 + + +async def test_second_poll_does_not_flip_the_target(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY) + _feed(zone, *F461_POLL_REPLY) + assert zone.target_temperature == 23.5 + + +async def test_offset_is_never_added_to_dimension_12(hass): + """Dimension 12 already includes the knob offset: +2 with 23.5 in force stays 23.5.""" + zone = _zone(hass) + _feed(zone, "*#4*2*12*0235*3##", "*4*1*2##", "*#4*2*14*0210*3##", "*#4*2*13*02##") + assert zone._local_offset == 2 + assert zone.target_temperature == 23.5 + _feed(zone, "*#4*2*13*02##", "*#4*2*12*0235*3##", "*#4*2*14*0210*3##") + assert zone.target_temperature == 23.5 + + +async def test_without_dimension_12_the_commanded_setpoint_plus_offset_is_shown(hass): + zone = _zone(hass) + _feed(zone, "*4*1*2##", "*#4*2*14*0210*3##", "*#4*2*13*01##") + assert zone.target_temperature == 22.0 + + +async def test_manual_write_echo_updates_the_setpoint_in_force(hass): + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY) + # *#4*2*#14*0220*3## written; the gateway answers with dimension 12 and the season + _feed(zone, "*#4*2*12*0220*3##", "*4*1*2##") + assert zone.target_temperature == 22.0 + assert zone._target_temperature == 22.0 + + +async def test_protection_then_back_on_shows_the_nominal_again(hass, monkeypatch): + """Off with a 7.0 protection setpoint, then on: the nominal comes back (#383), not 7.0.""" + clock = [1000.0] + monkeypatch.setattr(climate_module.time, "monotonic", lambda: clock[0]) + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY) + _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*14*0210*3##", "*#4*2*13*00##") + assert zone.target_temperature == 7.0 + _feed(zone, "*4*1*2##") + assert zone.hvac_mode == HVACMode.HEAT + assert zone.target_temperature == 21.0 + # the next poll reports what the thermostat regulates to again + _feed(zone, "*#4*2*12*0235*3##", "*#4*2*14*0210*3##") + assert zone.target_temperature == 23.5 + + +async def test_nothing_is_shown_before_any_setpoint_is_known(hass): + """Leaving OFF before the zone ever reported a setpoint shows no target rather than an offset.""" + zone = _zone(hass) + _feed(zone, "*#4*2*13*01##") + zone._show_nominal() + assert zone.target_temperature is None + assert zone._local_target_reported is False + + +# ------------------------------------------------------------------ the write and the OFF -> on order + +async def test_set_temperature_writes_dimension_14_and_shows_the_echo(hass): + """``climate.set_temperature`` 22.0 writes ``*#4*2*#14*0220*3##``; the echo decides the display. + + The bus monitor of the live plant showed ``*#4*2*12*0220*3##`` then ``*4*1*2##`` after the + write. A gateway that echoes only dimension 14 must also update the display: the write + clears the dimension 12 hold, so neither echo leaves the old value in force on screen. + """ + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY) + assert zone.target_temperature == 23.5 + await zone.async_set_temperature(temperature=22.0) + zone._gateway_handler.send.assert_awaited_once() + assert str(zone._gateway_handler.send.await_args.args[0]) == "*#4*2*#14*0220*3##" + assert zone.target_temperature == 23.5 # nothing echoed yet + assert zone._local_target_reported is False + _feed(zone, "*#4*2*14*0220*3##") # a dimension 14-only echo is not stale any more + assert zone.target_temperature == 22.0 + _feed(zone, "*#4*2*12*0220*3##", "*4*1*2##") # the F461 echo + assert zone.target_temperature == 22.0 + assert zone._local_target_reported is True + + +async def test_dimension_12_just_before_leaving_off_is_treated_as_protection(hass, monkeypatch): + """The live reply order puts dimension 12 before the season frame. + + While the zone is still OFF that value may be the protection setpoint (#383), so leaving + OFF shows the nominal setpoint; the next dimension 12 report of the running zone wins. + """ + clock = [1000.0] + monkeypatch.setattr(climate_module.time, "monotonic", lambda: clock[0]) + zone = _zone(hass) + _feed(zone, *F461_POLL_REPLY) + _feed(zone, "*#4*2*12*0070*3##", "*4*102*2##") + assert zone.hvac_mode == HVACMode.OFF + # next poll: the zone was switched on at the thermostat; dimension 12 precedes the season + _feed(zone, "*#4*2*0*0239##", "*#4*2*12*0235*3##") + assert zone.target_temperature == 23.5 + _feed(zone, "*4*1*2##") + assert zone.hvac_mode == HVACMode.HEAT + assert zone.target_temperature == 21.0 # nominal until the running zone reports again + assert zone._local_target_reported is False + _feed(zone, "*#4*2*14*0210*3##", "*#4*2*13*00##") + assert zone.target_temperature == 21.0 + _feed(zone, "*#4*2*12*0235*3##") + assert zone.target_temperature == 23.5 + assert zone._local_target_reported is True From abf6330fbf8a21a64ed2cd5f2efec26992863429 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 21:14:51 +0300 Subject: [PATCH 2/2] docs(climate): state the dimension-14-only display limit in Known Limitations and the release note Review follow-up on #791: the limit (a commanded setpoint that arrives without a following dimension 12 does not move the slider once the zone has reported a setpoint in force) was only in the PR body. It is now a row in the WHO 4 section of docs/configuration/known_limitations.md and a sentence in the b16 release note, with the two captured plants that always emit dimension 12 named. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- docs/configuration/known_limitations.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 640c9df77..cc2c82bb5 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -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. +- **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). - **`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). diff --git a/docs/configuration/known_limitations.md b/docs/configuration/known_limitations.md index 376e7a471..a919a72f2 100644 --- a/docs/configuration/known_limitations.md +++ b/docs/configuration/known_limitations.md @@ -40,6 +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**#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. | | **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)