diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index f1d672824..cc2c82bb5 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. 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/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/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) 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