Skip to content

Commit 579c862

Browse files
authored
TRVZB (#1861)
* trying to fix the sonoff trvzb calibration problem * maintenance * maintenance * mpc virtual temp * mpc virtual temp * mpc virtual temp * trvzb quirk and maintenance * maintenance and mpc virtual temp * trv profile and performance curve * block learning after window is closed again * mpc learn fix * remove solar gains * mpc force loss learning * mpc learning bugfix * mpc save trv profile * ka sensor - learning clampings * new icon and lint
1 parent 9bb50da commit 579c862

8 files changed

Lines changed: 745 additions & 203 deletions

File tree

custom_components/better_thermostat/climate.py

Lines changed: 198 additions & 95 deletions
Large diffs are not rendered by default.

custom_components/better_thermostat/events/temperature.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,14 @@ async def _apply_temperature_update(self, new_temp):
143143
"better_thermostat %s: external_temperature write to TRV failed (non critical)",
144144
self.device_name,
145145
)
146-
# Enqueue control action
146+
# Enqueue control action (skip during valve maintenance to avoid overwriting exercise).
147+
# Still mark that a control cycle is needed after maintenance so we immediately
148+
# resume with the latest temperature.
147149
if self.control_queue_task is not None:
148-
await self.control_queue_task.put(self)
150+
if getattr(self, "in_maintenance", False):
151+
self._control_needed_after_maintenance = True
152+
else:
153+
await self.control_queue_task.put(self)
149154
_LOGGER.debug(
150155
"better_thermostat %s: _apply_temperature_update finished", self.device_name
151156
)

custom_components/better_thermostat/events/window.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,14 @@ async def window_queue(self):
113113
if current_window_state == window_event_to_process:
114114
self.window_open = window_event_to_process
115115
self.async_write_ha_state()
116-
if not self.control_queue_task.empty():
117-
empty_queue(self.control_queue_task)
118-
await self.control_queue_task.put(self)
116+
if getattr(self, "in_maintenance", False):
117+
# Keep state up to date during maintenance, but defer control
118+
# until maintenance ends.
119+
self._control_needed_after_maintenance = True
120+
else:
121+
if not self.control_queue_task.empty():
122+
empty_queue(self.control_queue_task)
123+
await self.control_queue_task.put(self)
119124
except asyncio.CancelledError:
120125
raise
121126
finally:

custom_components/better_thermostat/model_fixes/TRVZB.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,32 @@
44
percentages and mirroring external temperature into the TRV when supported.
55
"""
66

7+
import asyncio
78
import logging
89

910
from homeassistant.helpers import entity_registry as er
1011

1112
_LOGGER = logging.getLogger(__name__)
1213

14+
VALVE_MAINTENANCE_INTERVAL_HOURS = 24
15+
16+
# Some users report that the TRVZB motor can occasionally lose its calibration and
17+
# fail to fully close the valve when commanded to very small openings.
18+
#
19+
# Workaround: when requesting a further close (target_pct < last_pct), briefly
20+
# command the valve to open a bit more and then to the requested target.
21+
_TRVZB_CLOSE_BUMP_OPEN_DELTA_PCT = 10
22+
_TRVZB_CLOSE_BUMP_DELAY_S = 5.0
23+
24+
25+
def _cancel_pending_valve_bump(trv_state: dict) -> None:
26+
task = trv_state.pop("_trvzb_valve_bump_task", None)
27+
if task is not None:
28+
try:
29+
task.cancel()
30+
except Exception:
31+
pass
32+
1333

1434
def fix_local_calibration(self, entity_id, offset):
1535
"""Return unchanged local calibration for TRVZB by default."""
@@ -215,7 +235,67 @@ async def override_set_valve(self, entity_id, percent: int):
215235
Returns True if handled (write attempted), False to let adapter fallback run.
216236
"""
217237
try:
218-
ok = await maybe_set_sonoff_valve_percent(self, entity_id, percent)
238+
target_pct = max(0, min(100, int(percent)))
239+
240+
trv_state = self.real_trvs.get(entity_id)
241+
if not isinstance(trv_state, dict):
242+
return False
243+
244+
# During valve maintenance we don't want to add additional delayed steps.
245+
if getattr(self, "in_maintenance", False):
246+
ok = await maybe_set_sonoff_valve_percent(self, entity_id, target_pct)
247+
return bool(ok)
248+
249+
# Cancel any previous pending delayed "bump then set".
250+
_cancel_pending_valve_bump(trv_state)
251+
252+
last_pct_raw = trv_state.get("last_valve_percent")
253+
try:
254+
last_pct = None if last_pct_raw is None else int(last_pct_raw)
255+
except Exception:
256+
last_pct = None
257+
258+
# If we don't know the last commanded percent, just set directly.
259+
if last_pct is None:
260+
ok = await maybe_set_sonoff_valve_percent(self, entity_id, target_pct)
261+
return bool(ok)
262+
263+
# Only apply workaround when closing further.
264+
if target_pct < last_pct:
265+
bump_pct = min(100, int(last_pct) + _TRVZB_CLOSE_BUMP_OPEN_DELTA_PCT)
266+
267+
# If we can't "bump open", fall back to direct set.
268+
ok_bump = await maybe_set_sonoff_valve_percent(self, entity_id, bump_pct)
269+
if not ok_bump:
270+
ok = await maybe_set_sonoff_valve_percent(self, entity_id, target_pct)
271+
return bool(ok)
272+
273+
seq = int(trv_state.get("_trvzb_valve_bump_seq", 0)) + 1
274+
trv_state["_trvzb_valve_bump_seq"] = seq
275+
276+
async def _delayed_set():
277+
try:
278+
await asyncio.sleep(float(_TRVZB_CLOSE_BUMP_DELAY_S))
279+
cur_state = self.real_trvs.get(entity_id, {}) or {}
280+
if int(cur_state.get("_trvzb_valve_bump_seq", 0)) != seq:
281+
return
282+
await maybe_set_sonoff_valve_percent(self, entity_id, target_pct)
283+
except asyncio.CancelledError:
284+
return
285+
except Exception as ex:
286+
_LOGGER.debug(
287+
"better_thermostat %s: TRVZB delayed valve set exception: %s",
288+
getattr(self, "device_name", "unknown"),
289+
ex,
290+
)
291+
292+
trv_state["_trvzb_valve_bump_task"] = self.hass.async_create_task(
293+
_delayed_set()
294+
)
295+
return True
296+
297+
# Opening (or same) => set directly.
298+
ok = await maybe_set_sonoff_valve_percent(self, entity_id, target_pct)
219299
return bool(ok)
220300
except Exception:
221301
return False

custom_components/better_thermostat/model_fixes/default.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
STATE_UNLOCKED = "unlocked"
1414
_LOGGER = logging.getLogger(__name__)
1515

16+
VALVE_MAINTENANCE_INTERVAL_HOURS = 168 # Default: 7 days
17+
1618

1719
def fix_local_calibration(self, entity_id, offset):
1820
"""Return the given local calibration offset unchanged."""

custom_components/better_thermostat/sensor.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ async def async_setup_entry(
5454
BetterThermostatVirtualTempSensor(bt_climate),
5555
BetterThermostatMpcGainSensor(bt_climate),
5656
BetterThermostatMpcLossSensor(bt_climate),
57+
BetterThermostatMpcKaSensor(bt_climate),
5758
BetterThermostatMpcStatusSensor(bt_climate),
5859
]
5960
)
@@ -328,6 +329,61 @@ def _update_state(self):
328329
self._attr_native_value = None
329330

330331

332+
class BetterThermostatMpcKaSensor(SensorEntity):
333+
"""Representation of a Better Thermostat MPC Insulation (Ka) Sensor."""
334+
335+
_attr_has_entity_name = True
336+
_attr_name = "MPC Insulation"
337+
_attr_device_class = None
338+
_attr_state_class = SensorStateClass.MEASUREMENT
339+
_attr_native_unit_of_measurement = "1/min"
340+
_attr_should_poll = False
341+
_attr_icon = "mdi:home-thermometer"
342+
_attr_entity_category = EntityCategory.DIAGNOSTIC
343+
344+
def __init__(self, bt_climate):
345+
"""Initialize the sensor."""
346+
self._bt_climate = bt_climate
347+
self._attr_unique_id = f"{bt_climate.unique_id}_mpc_ka"
348+
self._attr_device_info = bt_climate.device_info
349+
350+
async def async_added_to_hass(self):
351+
"""Register callbacks."""
352+
if self._bt_climate.entity_id:
353+
self.async_on_remove(
354+
async_track_state_change_event(
355+
self.hass, [self._bt_climate.entity_id], self._on_climate_update
356+
)
357+
)
358+
self._update_state()
359+
360+
@callback
361+
def _on_climate_update(self, event):
362+
"""Handle climate entity update."""
363+
self._update_state()
364+
self.async_write_ha_state()
365+
366+
def _update_state(self):
367+
"""Update state from climate entity."""
368+
val = None
369+
if hasattr(self._bt_climate, "real_trvs"):
370+
for trv_id, trv_data in self._bt_climate.real_trvs.items():
371+
cal_bal = trv_data.get("calibration_balance")
372+
if cal_bal and "debug" in cal_bal:
373+
debug = cal_bal["debug"]
374+
if "mpc_ka" in debug:
375+
val = debug["mpc_ka"]
376+
break
377+
378+
if val is not None:
379+
try:
380+
self._attr_native_value = float(val)
381+
except (ValueError, TypeError):
382+
self._attr_native_value = None
383+
else:
384+
self._attr_native_value = None
385+
386+
331387
class BetterThermostatSolarIntensitySensor(SensorEntity):
332388
"""Representation of a Better Thermostat Solar Intensity Sensor."""
333389

0 commit comments

Comments
 (0)