Skip to content

Commit 6167c0f

Browse files
committed
Merge branch 'refs/heads/public-develop'
2 parents 8a25c12 + e76591b commit 6167c0f

File tree

4 files changed

+83
-69
lines changed

4 files changed

+83
-69
lines changed

custom_components/sat/climate.py

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from .coordinator import SatDataUpdateCoordinator, DeviceState
4141
from .entity import SatEntity
4242
from .errors import Errors, Error
43-
from .helpers import convert_time_str_to_seconds
43+
from .helpers import convert_time_str_to_seconds, is_state_stale, state_age_seconds
4444
from .manufacturers.geminox import Geminox
4545
from .pwm import PWMState
4646
from .relative_modulation import RelativeModulation, RelativeModulationState
@@ -90,20 +90,6 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn
9090
self.inside_sensor_entity_id = config_entry.data.get(CONF_INSIDE_SENSOR_ENTITY_ID)
9191
self.humidity_sensor_entity_id = config_entry.data.get(CONF_HUMIDITY_SENSOR_ENTITY_ID)
9292

93-
# Get some sensor entity states
94-
inside_sensor_entity = coordinator.hass.states.get(self.inside_sensor_entity_id)
95-
humidity_sensor_entity = coordinator.hass.states.get(self.humidity_sensor_entity_id) if self.humidity_sensor_entity_id is not None else None
96-
97-
# Get current temperature
98-
self._current_temperature = None
99-
if inside_sensor_entity is not None and inside_sensor_entity.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
100-
self._current_temperature = float(inside_sensor_entity.state)
101-
102-
# Get current temperature
103-
self._current_humidity = None
104-
if humidity_sensor_entity is not None and humidity_sensor_entity.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
105-
self._current_humidity = float(humidity_sensor_entity.state)
106-
10793
# Get outside sensor entity IDs
10894
self.outside_sensor_entities = config_entry.data.get(CONF_OUTSIDE_SENSOR_ENTITY_ID)
10995

@@ -405,10 +391,10 @@ def extra_state_attributes(self):
405391

406392
"rooms": self._rooms,
407393
"setpoint": self._setpoint,
408-
"current_humidity": self._current_humidity,
394+
"current_humidity": self.current_humidity,
409395

410-
"summer_simmer_index": SummerSimmer.index(self._current_temperature, self._current_humidity),
411-
"summer_simmer_perception": SummerSimmer.perception(self._current_temperature, self._current_humidity),
396+
"summer_simmer_index": SummerSimmer.index(self.current_temperature, self.current_humidity),
397+
"summer_simmer_perception": SummerSimmer.perception(self.current_temperature, self.current_humidity),
412398

413399
"valves_open": self.valves_open,
414400
"heating_curve": self.heating_curve.value,
@@ -428,23 +414,29 @@ def extra_state_attributes(self):
428414
}
429415

430416
@property
431-
def current_temperature(self):
417+
def current_temperature(self) -> Optional[float]:
432418
"""Return the sensor temperature."""
433-
if self._thermal_comfort and self._current_humidity is not None:
434-
return SummerSimmer.index(self._current_temperature, self._current_humidity)
419+
if (current_temperature := self._get_entity_state_float(self.inside_sensor_entity_id)) is None:
420+
return None
421+
422+
if self._thermal_comfort:
423+
return SummerSimmer.index(current_temperature, self.current_humidity)
424+
425+
return current_temperature
435426

436-
return self._current_temperature
427+
@property
428+
def current_humidity(self) -> Optional[float]:
429+
"""Return the sensor humidity."""
430+
if self.humidity_sensor_entity_id is None:
431+
return None
432+
433+
return self._get_entity_state_float(self.humidity_sensor_entity_id)
437434

438435
@property
439436
def target_temperature(self):
440437
"""Return the temperature we try to reach."""
441438
return self._target_temperature
442439

443-
@property
444-
def current_humidity(self):
445-
"""Return the sensor humidity."""
446-
return self._current_humidity
447-
448440
@property
449441
def error(self):
450442
"""Return the error value."""
@@ -632,7 +624,6 @@ async def _async_inside_sensor_changed(self, event: Event[EventStateChangedData]
632624
return
633625

634626
_LOGGER.debug("Inside Sensor Changed.")
635-
self._current_temperature = float(new_state.state)
636627
self.async_write_ha_state()
637628

638629
self._async_control_pid()
@@ -656,7 +647,6 @@ async def _async_humidity_sensor_changed(self, event: Event[EventStateChangedDat
656647
return
657648

658649
_LOGGER.debug("Humidity Sensor Changed.")
659-
self._current_humidity = float(new_state.state)
660650
self.async_write_ha_state()
661651

662652
self._async_control_pid()
@@ -1135,3 +1125,22 @@ async def async_send_notification(self, title: str, message: str, service: str =
11351125
"""Send a notification to the user."""
11361126
data = {"title": title, "message": message}
11371127
await self.hass.services.async_call(notify.DOMAIN, service, data)
1128+
1129+
def _get_entity_state_float(self, entity_id: str) -> Optional[float]:
1130+
"""Return state if available and valid."""
1131+
if entity_id is None:
1132+
return None
1133+
1134+
if (entity := self.hass.states.get(entity_id)) is None:
1135+
return None
1136+
1137+
sensor_max_value_age = self._sensor_max_value_age
1138+
1139+
if is_state_stale(entity, sensor_max_value_age):
1140+
_LOGGER.debug("Sensor %s stale for %s (age=%.1fs > %.1fs)", entity_id, self.entity_id, state_age_seconds(entity), sensor_max_value_age)
1141+
return None
1142+
1143+
if entity.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
1144+
return None
1145+
1146+
return float(entity.state)

custom_components/sat/flame.py

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,6 @@ def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None
172172
self._last_boiler_state = boiler_state
173173
self._last_pulse_width_modulation_state = pwm_state or self._last_pulse_width_modulation_state
174174

175-
_LOGGER.debug("Flame active=%s->%s, last_update=%.1fs", previously_active, currently_active, elapsed)
176-
177175
if previously_active and elapsed > 0.0:
178176
self._on_deltas_window.append((now, elapsed))
179177

@@ -195,28 +193,13 @@ def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None
195193
self._latest_on_time_seconds = now - self._flame_on_monotonic
196194

197195
if self._has_completed_first_cycle:
198-
alpha = self._smoothing_alpha
199-
previous_average = self._average_on_time_seconds
200-
201196
self._average_on_time_seconds = (
202197
self._latest_on_time_seconds
203198
if self._average_on_time_seconds is None
204-
else (1.0 - alpha) * self._average_on_time_seconds + alpha * self._latest_on_time_seconds
199+
else (1.0 - self._smoothing_alpha) * self._average_on_time_seconds + self._smoothing_alpha * self._latest_on_time_seconds
205200
)
206-
else:
207-
previous_average = self._average_on_time_seconds
208201

209202
self._last_update_monotonic = now
210-
211-
_LOGGER.debug(
212-
"Flame transition ON->ON: latest_on=%.1fs, average_on=%s->%s, cycles_last_hour=%.1f, duty_ratio_15m=%.2f",
213-
self._latest_on_time_seconds,
214-
previous_average,
215-
self._average_on_time_seconds,
216-
self._cycles_per_hour(now),
217-
self._duty_ratio_last_window(now),
218-
)
219-
220203
self._recompute_health(now)
221204
return
222205

@@ -252,14 +235,6 @@ def update(self, boiler_state: BoilerState, pwm_state: Optional[PWMState] = None
252235
self._flame_off_monotonic = now
253236

254237
self._last_update_monotonic = now
255-
256-
_LOGGER.debug(
257-
"Flame transition OFF->OFF: off_since=%.1fs, cycles_last_hour=%.1f, duty_ratio_15m=%.2f",
258-
None if self._flame_off_monotonic is None else now - self._flame_off_monotonic,
259-
self._cycles_per_hour(now),
260-
self._duty_ratio_last_window(now),
261-
)
262-
263238
self._recompute_health(now)
264239
return
265240

@@ -293,8 +268,6 @@ def _recompute_health(self, now: float) -> None:
293268
timeout = self.MAX_DOMESTIC_HOT_WATER_IDLE_OFF_SECONDS if domestic_hot_water_active else self.STUCK_OFF_SECONDS
294269
if (not state.flame_active) and last_off_seconds > timeout and state.status not in self._TRANSIENT_STATUSES:
295270
self._health_status = FlameStatus.STUCK_OFF
296-
else:
297-
self._health_status = FlameStatus.INSUFFICIENT_DATA
298271

299272
return
300273

custom_components/sat/helpers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from time import monotonic
44
from typing import Optional, Union
55

6+
from homeassistant.core import State
67
from homeassistant.util import dt
78

89
from .const import HEATING_SYSTEM_UNDERFLOOR
@@ -16,6 +17,19 @@ def seconds_since(start_time: float | None) -> float:
1617
return monotonic() - start_time
1718

1819

20+
def state_age_seconds(state: State) -> float:
21+
"""Return the age of a HA state in seconds."""
22+
return (dt.utcnow() - state.last_updated).total_seconds()
23+
24+
25+
def is_state_stale(state: Optional[State], max_age_seconds: float) -> bool:
26+
"""Return True when the state is older than max_age_seconds."""
27+
if state is None or max_age_seconds <= 0:
28+
return False
29+
30+
return state_age_seconds(state) > max_age_seconds
31+
32+
1933
def convert_time_str_to_seconds(time_str: str) -> int:
2034
"""Convert a time string in the format 'HH:MM:SS' to seconds."""
2135
try:

custom_components/sat/serial/__init__.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,14 @@ def __init__(self, hass: HomeAssistant, port: str, config_data: Mapping[str, Any
3131
self.async_set_updated_data(DEFAULT_STATUS)
3232

3333
self._port: str = port
34+
self._hass_loop = hass.loop
3435
self._api: OpenThermGateway = OpenThermGateway()
35-
self._api.subscribe(lambda data: self.async_set_updated_data(data))
36+
37+
async def _publish(data: dict) -> None:
38+
self._hass_loop.call_soon_threadsafe(self.async_set_updated_data, data)
39+
40+
self._publish_callback = _publish
41+
self._api.subscribe(self._publish_callback)
3642

3743
@property
3844
def device_id(self) -> str:
@@ -137,7 +143,7 @@ def maximum_relative_modulation_value(self) -> Optional[float]:
137143
return super().maximum_relative_modulation_value
138144

139145
@property
140-
def member_id(self) -> int | None:
146+
def member_id(self) -> Optional[int]:
141147
if (value := self.get(DATA_SLAVE_MEMBERID)) is not None:
142148
return int(value)
143149

@@ -148,11 +154,7 @@ def flame_active(self) -> bool:
148154
return bool(self.get(DATA_SLAVE_FLAME_ON))
149155

150156
def get(self, key: str) -> Optional[Any]:
151-
"""Get the value for the given `key` from the boiler data.
152-
153-
:param key: Key of the value to retrieve from the boiler data.
154-
:return: Value for the given key from the boiler data, or None if the boiler data or the value are not available.
155-
"""
157+
"""Get the value for the given `key` from the boiler data."""
156158
return self.data[BOILER].get(key)
157159

158160
async def async_connect(self) -> SatSerialCoordinator:
@@ -167,11 +169,15 @@ async def async_setup(self) -> None:
167169
await self.async_connect()
168170

169171
async def async_will_remove_from_hass(self) -> None:
170-
self._api.unsubscribe(self.async_set_updated_data)
172+
if self._publish_callback is not None:
173+
try:
174+
self._api.unsubscribe(self._publish_callback)
175+
except Exception: # pragma: no cover - best effort cleanup
176+
_LOGGER.debug("Failed to unsubscribe serial listener", exc_info=True)
177+
finally:
178+
self._publish_callback = None
171179

172-
await self._api.set_control_setpoint(0)
173-
await self._api.set_max_relative_mod("-")
174-
await self._api.disconnect()
180+
await self._graceful_disconnect()
175181

176182
async def async_set_control_setpoint(self, value: float) -> None:
177183
if not self._simulation:
@@ -183,7 +189,7 @@ async def async_set_control_hot_water_setpoint(self, value: float) -> None:
183189
if not self._simulation:
184190
await self._api.set_dhw_setpoint(value)
185191

186-
await super().async_set_control_thermostat_setpoint(value)
192+
await super().async_set_control_hot_water_setpoint(value)
187193

188194
async def async_set_control_thermostat_setpoint(self, value: float) -> None:
189195
if not self._simulation:
@@ -208,3 +214,15 @@ async def async_set_control_max_setpoint(self, value: float) -> None:
208214
await self._api.set_max_ch_setpoint(value)
209215

210216
await super().async_set_control_max_setpoint(value)
217+
218+
async def _graceful_disconnect(self) -> None:
219+
try:
220+
await self._api.set_control_setpoint(0)
221+
await self._api.set_max_relative_mod("-")
222+
except Exception: # pragma: no cover - best effort cleanup
223+
_LOGGER.debug("Failed to reset serial gateway state before disconnect", exc_info=True)
224+
225+
try:
226+
await self._api.disconnect()
227+
except Exception: # pragma: no cover - best effort cleanup
228+
_LOGGER.debug("Error while disconnecting serial gateway", exc_info=True)

0 commit comments

Comments
 (0)