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
1 change: 1 addition & 0 deletions .github/release-notes/2.0.0b16.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
38 changes: 30 additions & 8 deletions custom_components/myhome/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions docs/configuration/climate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
1 change: 1 addition & 0 deletions docs/configuration/known_limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*<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. |
| **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
190 changes: 190 additions & 0 deletions tests/test_climate_setpoint_in_force.py
Original file line number Diff line number Diff line change
@@ -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
Loading