Skip to content

Commit 64a4f8b

Browse files
committed
Improved area presets and weighting
1 parent a3c0470 commit 64a4f8b

File tree

8 files changed

+294
-110
lines changed

8 files changed

+294
-110
lines changed

custom_components/sat/area.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,7 @@ def __init__(self, areas, percentile: float = 0.75, headroom: float = 5.0):
297297
@property
298298
def output(self) -> float:
299299
"""Aggregate PID output + count for areas that are calling for heat."""
300-
area_count = 0
301-
outputs: list[float] = []
300+
outputs: list[tuple[float, float]] = []
302301

303302
for area in self._areas:
304303
if not area.pid.available or not area.requires_heat:
@@ -310,18 +309,34 @@ def output(self) -> float:
310309
_LOGGER.warning("Failed to compute PID output for area %s: %s", area.id, exception)
311310
continue
312311

313-
area_count += 1
314-
outputs.append(value)
312+
weight = area.demand_weight
313+
if weight is None or weight <= 0.0:
314+
weight = area.room_weight
315+
316+
outputs.append((value, weight))
315317

316318
if not outputs:
317319
return MINIMUM_SETPOINT
318320

319-
outputs.sort()
320-
index = max(0, min(len(outputs) - 1, int(len(outputs) * self._percentile)))
321-
baseline = outputs[index]
321+
outputs.sort(key=lambda item: item[0])
322+
323+
total_weight = sum(weight for _, weight in outputs)
324+
if total_weight <= 0.0:
325+
baseline = outputs[-1][0]
326+
else:
327+
cumulative = 0.0
328+
baseline = outputs[-1][0]
329+
threshold = total_weight * self._percentile
322330

331+
for value, weight in outputs:
332+
cumulative += weight
333+
if cumulative >= threshold:
334+
baseline = value
335+
break
336+
337+
max_output = outputs[-1][0]
323338
allowed = baseline + self._headroom
324-
chosen = min(outputs[-1], allowed)
339+
chosen = min(max_output, allowed)
325340

326341
return round(chosen, 1)
327342

@@ -331,7 +346,7 @@ def overshoot_cap(self) -> Optional[float]:
331346
caps: list[float] = []
332347

333348
for area in self._areas:
334-
if not area.pid.available or not area.requires_heat:
349+
if not area.pid.available:
335350
continue
336351

337352
if (temperature_state := area.temperature_state) is None:

custom_components/sat/climate.py

Lines changed: 15 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
from .temperature.state import TemperatureState
4444
from .types import PWMStatus, HeatingMode
4545

46-
ATTR_ROOMS = "rooms"
4746
ATTR_SETPOINT = "setpoint"
4847
ATTR_RELATIVE_MODULATION_VALUE = "relative_modulation_value"
4948

@@ -81,7 +80,6 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, heating_control: SatHe
8180

8281
self._sensors: dict[str, str] = {}
8382

84-
self._rooms: Optional[dict[str, float]] = None
8583
self._presets: dict[str, float] = self._build_presets(config.presets.presets)
8684

8785
self._target_temperature: Optional[float] = None
@@ -177,7 +175,6 @@ def extra_state_attributes(self) -> dict[str, Any]:
177175
"pre_custom_temperature": self._pre_custom_temperature,
178176
"pre_activity_temperature": self._pre_activity_temperature,
179177

180-
"rooms": self._rooms,
181178
"current_humidity": self.current_humidity,
182179
"setpoint": self._heating_control.control_setpoint,
183180

@@ -541,10 +538,8 @@ async def async_set_preset_mode(self, preset_mode: str) -> None:
541538
if state is None or state.state == HVACMode.OFF:
542539
continue
543540

544-
if preset_mode != PRESET_HOME:
545-
target_temperature = self._presets[preset_mode]
546-
else:
547-
target_temperature = self._rooms.get(entity_id, self._presets[preset_mode])
541+
if (target_temperature := self._room_preset_target(entity_id)) is None:
542+
continue
548543

549544
data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: target_temperature}
550545
await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True)
@@ -691,15 +686,7 @@ async def _restore_previous_state_or_set_defaults(self) -> None:
691686

692687
if old_state.attributes.get(ATTR_PRE_CUSTOM_TEMPERATURE):
693688
self._pre_custom_temperature = old_state.attributes.get(ATTR_PRE_CUSTOM_TEMPERATURE)
694-
695-
if old_state.attributes.get(ATTR_ROOMS):
696-
self._rooms = old_state.attributes.get(ATTR_ROOMS)
697-
else:
698-
await self._async_update_rooms_from_climates()
699689
else:
700-
if self._rooms is None:
701-
await self._async_update_rooms_from_climates()
702-
703690
if self._target_temperature is None:
704691
self.pid.control_setpoint = self.min_temp
705692
self._target_temperature = self.min_temp
@@ -747,27 +734,11 @@ async def _async_entity_changed(self, event: Event[EventStateChangedData]) -> No
747734

748735
async def _async_climate_changed(self, event: Event[EventStateChangedData]) -> None:
749736
"""Handle changes to a climate entity."""
750-
new_state = event.data.get("new_state")
751-
752-
if not new_state or self._rooms is None:
753-
return
754-
755-
# Get the attributes of the new state
756-
new_attrs = new_state.attributes
757-
758-
if (target_temperature := new_attrs.get("temperature")) is None:
759-
return
760-
761-
if float(target_temperature) == self._rooms.get(new_state.entity_id, target_temperature):
737+
if not (new_state := event.data.get("new_state")) or new_state.attributes.get("temperature") is None:
762738
return
763739

764-
if new_state.entity_id not in self._rooms or self.preset_mode == PRESET_HOME:
765-
self._rooms[new_state.entity_id] = float(target_temperature)
766-
_LOGGER.debug(f"Updated area preset temperature for {new_state.entity_id} to {target_temperature}")
767-
768740
self.areas.pids.reset(new_state.entity_id)
769741
self._update_heating_curves()
770-
771742
self.async_write_ha_state()
772743

773744
async def _async_window_sensor_changed(self, event: Event[EventStateChangedData]) -> None:
@@ -806,27 +777,20 @@ async def _async_window_sensor_changed(self, event: Event[EventStateChangedData]
806777

807778
return
808779

809-
async def _async_update_rooms_from_climates(self) -> None:
810-
"""Update the temperature setpoint for each room based on their associated climate entity."""
811-
self._rooms = {}
812-
813-
# Iterate through each climate entity
814-
for entity_id in self.areas.ids():
815-
state = self.hass.states.get(entity_id)
816-
817-
# Skip any entities that are unavailable or have an unknown state
818-
if not state or state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
819-
continue
820-
821-
# Retrieve the target temperature from the climate entity's attributes
822-
target_temperature = state.attributes.get("temperature")
823-
824-
# If the target temperature exists, store it in the _rooms dictionary with the climate entity as the key
825-
if target_temperature is not None:
826-
self._rooms[entity_id] = float(target_temperature)
827-
828780
@staticmethod
829781
def _build_presets(config_options: Mapping[str, float]) -> dict[str, float]:
830782
"""Build preset temperature mapping from config options."""
831783
conf_presets = {p: f"{p}_temperature" for p in (PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)}
832784
return {key: config_options[value] for key, value in conf_presets.items() if key in conf_presets}
785+
786+
def _room_preset_target(self, entity_id: str) -> Optional[float]:
787+
preset_mode = self._attr_preset_mode
788+
if preset_mode == PRESET_NONE:
789+
return self._target_temperature
790+
791+
preset_temperature = self._presets.get(preset_mode)
792+
overrides = self._config.presets.preset_room_overrides.get(preset_mode, {})
793+
if (override := overrides.get(entity_id)) is not None:
794+
return float(override)
795+
796+
return preset_temperature if preset_temperature is not None else self._target_temperature

custom_components/sat/config_flow.py

Lines changed: 86 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
import voluptuous as vol
77
from homeassistant import config_entries
88
from homeassistant.components import sensor, switch, valve, weather, binary_sensor, climate, input_boolean
9+
from homeassistant.components.climate import (
10+
PRESET_ACTIVITY,
11+
PRESET_AWAY,
12+
PRESET_COMFORT,
13+
PRESET_HOME,
14+
PRESET_SLEEP,
15+
)
916
from homeassistant.config_entries import ConfigEntry
1017
from homeassistant.core import callback
1118
from homeassistant.helpers import selector, entity_registry
@@ -628,46 +635,96 @@ async def async_step_general(self, _user_input: Optional[dict[str, Any]] = None)
628635
)
629636
)
630637

638+
schema[vol.Required(CONF_SYNC_CLIMATES_WITH_PRESET, default=options[CONF_SYNC_CLIMATES_WITH_PRESET])] = bool
639+
schema[vol.Required(CONF_PUSH_SETPOINT_TO_THERMOSTAT, default=options[CONF_PUSH_SETPOINT_TO_THERMOSTAT])] = bool
640+
631641
return self.async_show_form(step_id="general", data_schema=vol.Schema(schema))
632642

633643
async def async_step_presets(self, _user_input: Optional[dict[str, Any]] = None):
634-
if _user_input is not None:
635-
return await self.update_options(_user_input)
644+
if len(self._config_entry.data.get(CONF_ROOMS, [])) > 0:
645+
return self.async_show_menu(
646+
step_id="presets",
647+
menu_options=[
648+
"preset_home",
649+
"preset_comfort",
650+
"preset_sleep",
651+
"preset_away",
652+
"preset_activity",
653+
]
654+
)
655+
656+
return await self.async_step_preset_home(_user_input)
657+
658+
async def async_step_preset_home(self, user_input: Optional[dict[str, Any]] = None):
659+
return await self._async_step_preset_settings(PRESET_HOME, CONF_HOME_TEMPERATURE, user_input)
660+
661+
async def async_step_preset_comfort(self, user_input: Optional[dict[str, Any]] = None):
662+
return await self._async_step_preset_settings(PRESET_COMFORT, CONF_COMFORT_TEMPERATURE, user_input)
663+
664+
async def async_step_preset_sleep(self, user_input: Optional[dict[str, Any]] = None):
665+
return await self._async_step_preset_settings(PRESET_SLEEP, CONF_SLEEP_TEMPERATURE, user_input)
666+
667+
async def async_step_preset_away(self, user_input: Optional[dict[str, Any]] = None):
668+
return await self._async_step_preset_settings(PRESET_AWAY, CONF_AWAY_TEMPERATURE, user_input)
669+
670+
async def async_step_preset_activity(self, user_input: Optional[dict[str, Any]] = None):
671+
return await self._async_step_preset_settings(PRESET_ACTIVITY, CONF_ACTIVITY_TEMPERATURE, user_input)
672+
673+
async def _async_step_preset_settings(self, preset: str, preset_option_key: str, user_input: Optional[dict[str, Any]] = None):
674+
primary_key = "primary_temperature"
675+
if user_input is not None:
676+
options = await self.get_options()
677+
overrides = dict(options.get(CONF_PRESET_ROOM_OVERRIDES) or {})
678+
room_overrides: dict[str, float] = {}
679+
680+
for entity_id in self._config_entry.data.get(CONF_ROOMS, []):
681+
raw_value = user_input.get(entity_id)
682+
if raw_value is None:
683+
continue
684+
685+
try:
686+
value = float(raw_value)
687+
except (TypeError, ValueError):
688+
continue
689+
690+
if abs(value - float(user_input[primary_key])) > 0.001:
691+
room_overrides[entity_id] = value
692+
693+
if room_overrides:
694+
overrides[preset] = room_overrides
695+
else:
696+
overrides.pop(preset, None)
697+
698+
update = {
699+
preset_option_key: user_input[primary_key],
700+
CONF_PRESET_ROOM_OVERRIDES: overrides,
701+
}
702+
703+
return await self.update_options(update)
636704

637705
options = await self.get_options()
638-
schema_entries: list[tuple[Marker, Any]] = [
639-
(
640-
vol.Required(CONF_HOME_TEMPERATURE, default=options[CONF_HOME_TEMPERATURE]),
641-
selector.NumberSelector(selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C")),
642-
),
643-
(
644-
vol.Required(CONF_COMFORT_TEMPERATURE, default=options[CONF_COMFORT_TEMPERATURE]),
645-
selector.NumberSelector(selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C")),
646-
),
647-
(
648-
vol.Required(CONF_SLEEP_TEMPERATURE, default=options[CONF_SLEEP_TEMPERATURE]),
649-
selector.NumberSelector(selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C")),
650-
),
651-
(
652-
vol.Required(CONF_AWAY_TEMPERATURE, default=options[CONF_AWAY_TEMPERATURE]),
653-
selector.NumberSelector(selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C")),
654-
),
655-
(
656-
vol.Required(CONF_ACTIVITY_TEMPERATURE, default=options[CONF_ACTIVITY_TEMPERATURE]),
657-
selector.NumberSelector(selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C")),
658-
),
659-
(vol.Required(CONF_SYNC_CLIMATES_WITH_PRESET, default=options[CONF_SYNC_CLIMATES_WITH_PRESET]), bool),
660-
(vol.Required(CONF_PUSH_SETPOINT_TO_THERMOSTAT, default=options[CONF_PUSH_SETPOINT_TO_THERMOSTAT]), bool),
661-
]
706+
overrides = options.get(CONF_PRESET_ROOM_OVERRIDES) or {}
707+
708+
preset_overrides = overrides.get(preset, {})
709+
preset_default = float(options[preset_option_key])
710+
711+
schema_fields: dict[Marker, Any] = {
712+
vol.Required(primary_key, default=preset_default): selector.NumberSelector(
713+
selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C")
714+
)
715+
}
716+
717+
for entity_id in self._config_entry.data.get(CONF_ROOMS, []):
718+
default_value = preset_overrides.get(entity_id, preset_default)
719+
schema_fields[vol.Required(entity_id, default=default_value)] = selector.NumberSelector(selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C"))
662720

663721
return self.async_show_form(
664-
step_id="presets",
665-
data_schema=vol.Schema({key: value for key, value in schema_entries})
722+
step_id=f"preset_{preset}",
723+
data_schema=vol.Schema(schema_fields),
666724
)
667725

668726
async def async_step_areas(self, user_input: Optional[dict[str, Any]] = None):
669727
room_weights: dict[str, float] = dict(self._options.get(CONF_ROOM_WEIGHTS, {}))
670-
671728
room_entity_ids: list[str] = list(self._config_entry.data.get(CONF_ROOMS, []))
672729

673730
# Build stable schema keys (entity_id) and friendly labels separately

custom_components/sat/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
CONF_MODULATION_SUPPRESSION_DELAY_SECONDS = "modulation_suppression_delay_seconds"
7575
CONF_MODULATION_SUPPRESSION_OFFSET_CELSIUS = "modulation_suppression_offset_celsius"
7676
CONF_FLOW_SETPOINT_OFFSET_CELSIUS = "flow_setpoint_offset_celsius"
77+
CONF_PRESET_ROOM_OVERRIDES = "preset_room_overrides"
7778
CONF_MINIMUM_BOILER_PRESSURE = "minimum_boiler_pressure"
7879
CONF_MAXIMUM_BOILER_PRESSURE = "maximum_boiler_pressure"
7980
CONF_MAXIMUM_PRESSURE_DROP_RATE = "maximum_pressure_drop_rate"
@@ -136,6 +137,7 @@
136137
CONF_MODULATION_SUPPRESSION_DELAY_SECONDS: 20,
137138
CONF_MODULATION_SUPPRESSION_OFFSET_CELSIUS: 1.0,
138139
CONF_FLOW_SETPOINT_OFFSET_CELSIUS: 2.0,
140+
CONF_PRESET_ROOM_OVERRIDES: {},
139141

140142
# Pressure health thresholds.
141143
CONF_MINIMUM_BOILER_PRESSURE: 0.8,

custom_components/sat/entry_data.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ class PresetConfig:
8585

8686
presets: Mapping[str, float]
8787
room_weights: Mapping[str, float]
88+
preset_room_overrides: Mapping[str, Mapping[str, float]]
8889

8990

9091
@dataclass(frozen=True)
@@ -259,6 +260,22 @@ def presets(self) -> PresetConfig:
259260
CONF_ACTIVITY_TEMPERATURE: self.options.get(CONF_ACTIVITY_TEMPERATURE),
260261
}
261262

263+
room_overrides_raw = self.options.get(CONF_PRESET_ROOM_OVERRIDES) or {}
264+
room_overrides: dict[str, dict[str, float]] = {}
265+
for preset, values in room_overrides_raw.items():
266+
if not isinstance(values, Mapping):
267+
continue
268+
269+
overrides: dict[str, float] = {}
270+
for entity_id, value in values.items():
271+
try:
272+
overrides[entity_id] = float(value)
273+
except (TypeError, ValueError):
274+
continue
275+
276+
if overrides:
277+
room_overrides[str(preset)] = overrides
278+
262279
return PresetConfig(
263280
heating_mode=heating_mode,
264281
thermal_comfort=bool(self.options.get(CONF_THERMAL_COMFORT)),
@@ -267,6 +284,7 @@ def presets(self) -> PresetConfig:
267284

268285
presets={key: float(value) for key, value in preset_values.items()},
269286
room_weights=self.options.get(CONF_ROOM_WEIGHTS) or {},
287+
preset_room_overrides=room_overrides,
270288
)
271289

272290
@property

0 commit comments

Comments
 (0)