diff --git a/README.md b/README.md index fea92e7..531afe5 100644 --- a/README.md +++ b/README.md @@ -62,20 +62,24 @@ Select one or more **climate entities** that represent the rooms whose heating d |---|---|---| | Destination climate entity | — | The thermostat that ClimateSync will control. | | Idle temperature | 5.0 °C | Target temperature sent to the destination when no room has a positive delta (all rooms are at or above their target). | -| Rounding mode | 1 decimal | How the computed setpoint is rounded before being sent. | +| Maximum setpoint | 35.0 °C | Hard ceiling for the destination setpoint. | +| Rounding mode | 1 decimal | Step size used for the computed setpoint. | +| Rounding direction | nearest | Whether the computed setpoint is rounded down, normally, or up within the selected rounding mode. | ### Options Flow — reconfigure everything via the settings gear After setup, open the integration → **Configure** (⚙ gear icon) to get the same 2-step wizard again. You can change: - **Step 1**: add or remove source rooms -- **Step 2**: change the destination thermostat, idle temperature, rounding mode, and advanced options: +- **Step 2**: change the destination thermostat, idle temperature, maximum setpoint, rounding mode, rounding direction, and advanced options: | Option | Default | Description | |---|---|---| | Destination thermostat | — | Change which thermostat is controlled. | | Idle temperature | 5.0 °C | Temperature sent when no room needs heating. | -| Rounding mode | 1 decimal | How setpoints are rounded. | +| Maximum setpoint | 35.0 °C | Hard ceiling for the destination setpoint. | +| Rounding mode | 1 decimal | Step size used for setpoints. | +| Rounding direction | nearest | `floor`, `nearest`, or `ceiling` rounding within the selected mode. | | Resync interval | 60 s | How often ClimateSync checks even without state changes. | | Minimum change threshold | 0.2 °C | Only send a new setpoint if the change exceeds this. | | Minimum send interval | 10 s | At most one service call per this many seconds. | @@ -104,14 +108,19 @@ Else: # Plugwise Emma that modulate boiler output based on their own # observed delta. -setpoint_final = round(setpoint_raw, rounding_mode) +rounded_setpoint = round_setpoint(setpoint_raw, rounding_mode, rounding_direction) +setpoint_final = min(rounded_setpoint, maximum_setpoint) If abs(destination_current_target - setpoint_final) > min_change_threshold: If time_since_last_call >= min_send_interval: climate.set_temperature(destination, setpoint_final) ``` -### Rounding modes +### Rounding mode and direction + +The **rounding mode** determines the step size. The **rounding direction** determines where the raw setpoint lands within that step size. + +`nearest` is the default and preserves the historical ClimateSync behaviour for existing installations. If an older config entry does not contain `rounding_direction`, ClimateSync treats it as `nearest`. | Mode | Example input | Result | |---|---|---| @@ -119,6 +128,26 @@ If abs(destination_current_target - setpoint_final) > min_change_threshold: | `1 decimal` | 19.33 | 19.3 | | `2 decimals` | 19.333 | 19.33 | +| Direction | Behaviour | +|---|---| +| `floor` | Always round down within the selected mode. | +| `nearest` | Round to the nearest step, matching the old behaviour. | +| `ceiling` | Always round up within the selected mode. | + +Plugwise Emma examples: + +| Mode + direction | Raw setpoint | Sent setpoint | +|---|---:|---:| +| `0.5 steps` + `ceiling` | 19.1 | 19.5 | +| `0.5 steps` + `ceiling` | 19.5 | 19.5 | +| `0.5 steps` + `ceiling` | 19.6 | 20.0 | +| `0.5 steps` + `floor` | 19.1 | 19.0 | +| `0.5 steps` + `floor` | 19.9 | 19.5 | +| `1 decimal` + `ceiling` | 19.11 | 19.2 | +| `1 decimal` + `floor` | 19.19 | 19.1 | + +For Plugwise Emma, `0.5 steps` can be useful because Emma commonly accepts half-degree setpoints. Combining `0.5 steps` with `ceiling` can help when small RoomMind deltas would otherwise disappear through normal rounding, for example a raw setpoint of 19.1 becoming 19.5 instead of 19.0. + --- ## Entities @@ -136,9 +165,13 @@ All entities are attached to a **ClimateSync** device. Sensors (setpoint, deltas | `destination_current_target` | Current target temperature at destination | | `delta_max` | Max delta used for this computation | | `rounding_mode` | Active rounding mode | +| `rounding_direction` | Active rounding direction | +| `raw_setpoint` | Unrounded `destination_current_temperature + delta_max`, or idle temperature when no room needs heating | +| `rounded_setpoint` | Raw setpoint after rounding mode and rounding direction | +| `final_setpoint` | Final setpoint after rounding and maximum-setpoint clamp | | `idle_temperature` | Configured idle temperature | -**State**: the rounded setpoint that ClimateSync wants to apply (`destination_current_temperature + delta_max`, rounded). +**State**: the final setpoint that ClimateSync wants to apply (`destination_current_temperature + delta_max`, rounded and capped by maximum setpoint). #### Max delta — `sensor.climatesync_2_delta_max` @@ -197,6 +230,11 @@ Shows the destination thermostat's actual current target temperature in real tim | `last_desired_setpoint` | What ClimateSync computed as the ideal setpoint | | `last_applied_setpoint` | What was last actually sent to the destination | | `current_destination_target` | The destination's actual `temperature` attribute right now | +| `rounding_mode` | Active rounding mode | +| `rounding_direction` | Active rounding direction | +| `raw_setpoint` | Last unrounded setpoint | +| `rounded_setpoint` | Last rounded setpoint before maximum-setpoint clamp | +| `final_setpoint` | Last final setpoint after rounding and clamp | | `mismatch_seconds` | How long (seconds) the desired and actual setpoint have been diverging | | `mismatch_since` | ISO timestamp of when the mismatch started (null when in sync) | | `resync_count` | Number of periodic resyncs since startup | @@ -213,7 +251,7 @@ Shows the destination thermostat's actual current target temperature in real tim ### Destination is not accepting the setpoint -Some thermostats (e.g. Plugwise Emma) only accept specific temperature steps. Use the **0.5 steps** rounding mode in that case. +Some thermostats (e.g. Plugwise Emma) only accept specific temperature steps. Use the **0.5 steps** rounding mode in that case. If small RoomMind deltas are rounded away, use **Rounding direction = ceiling** so a raw setpoint such as 19.1 is sent as 19.5 instead of 19.0. ### `mismatch_seconds` keeps growing diff --git a/custom_components/climatesync/config_flow.py b/custom_components/climatesync/config_flow.py index b61fb05..965454f 100644 --- a/custom_components/climatesync/config_flow.py +++ b/custom_components/climatesync/config_flow.py @@ -17,6 +17,7 @@ CONF_MIN_CHANGE_THRESHOLD, CONF_MIN_SEND_INTERVAL, CONF_RESYNC_INTERVAL, + CONF_ROUNDING_DIRECTION, CONF_ROUNDING_MODE, CONF_SOURCE_ENTITIES, DEFAULT_IDLE_TEMPERATURE, @@ -24,8 +25,10 @@ DEFAULT_MIN_CHANGE_THRESHOLD, DEFAULT_MIN_SEND_INTERVAL, DEFAULT_RESYNC_INTERVAL, + DEFAULT_ROUNDING_DIRECTION, DEFAULT_ROUNDING_MODE, DOMAIN, + ROUNDING_DIRECTIONS, ROUNDING_MODES, ) @@ -56,6 +59,7 @@ def _destination_schema( default_idle: float = DEFAULT_IDLE_TEMPERATURE, default_max_setpoint: float = DEFAULT_MAX_SETPOINT, default_rounding: str = DEFAULT_ROUNDING_MODE, + default_rounding_direction: str = DEFAULT_ROUNDING_DIRECTION, default_resync: int = DEFAULT_RESYNC_INTERVAL, default_threshold: float = DEFAULT_MIN_CHANGE_THRESHOLD, default_send_interval: int = DEFAULT_MIN_SEND_INTERVAL, @@ -99,6 +103,20 @@ def _destination_schema( } } ), + vol.Required( + CONF_ROUNDING_DIRECTION, + default=default_rounding_direction, + ): selector.selector( + { + "select": { + "options": [ + {"value": direction, "label": direction} + for direction in ROUNDING_DIRECTIONS + ], + "translation_key": "rounding_direction", + } + } + ), } if include_advanced: @@ -199,6 +217,10 @@ async def async_step_destination( CONF_IDLE_TEMPERATURE: user_input[CONF_IDLE_TEMPERATURE], CONF_MAX_SETPOINT: user_input[CONF_MAX_SETPOINT], CONF_ROUNDING_MODE: user_input[CONF_ROUNDING_MODE], + CONF_ROUNDING_DIRECTION: user_input.get( + CONF_ROUNDING_DIRECTION, + DEFAULT_ROUNDING_DIRECTION, + ), }, ) @@ -277,6 +299,10 @@ async def async_step_destination( CONF_IDLE_TEMPERATURE: user_input[CONF_IDLE_TEMPERATURE], CONF_MAX_SETPOINT: user_input[CONF_MAX_SETPOINT], CONF_ROUNDING_MODE: user_input[CONF_ROUNDING_MODE], + CONF_ROUNDING_DIRECTION: user_input.get( + CONF_ROUNDING_DIRECTION, + DEFAULT_ROUNDING_DIRECTION, + ), CONF_RESYNC_INTERVAL: user_input[CONF_RESYNC_INTERVAL], CONF_MIN_CHANGE_THRESHOLD: user_input[CONF_MIN_CHANGE_THRESHOLD], CONF_MIN_SEND_INTERVAL: user_input[CONF_MIN_SEND_INTERVAL], @@ -290,6 +316,9 @@ async def async_step_destination( default_idle=self._get(CONF_IDLE_TEMPERATURE, DEFAULT_IDLE_TEMPERATURE), default_max_setpoint=self._get(CONF_MAX_SETPOINT, DEFAULT_MAX_SETPOINT), default_rounding=self._get(CONF_ROUNDING_MODE, DEFAULT_ROUNDING_MODE), + default_rounding_direction=self._get( + CONF_ROUNDING_DIRECTION, DEFAULT_ROUNDING_DIRECTION + ), default_resync=self._get(CONF_RESYNC_INTERVAL, DEFAULT_RESYNC_INTERVAL), default_threshold=self._get( CONF_MIN_CHANGE_THRESHOLD, DEFAULT_MIN_CHANGE_THRESHOLD @@ -301,4 +330,3 @@ async def async_step_destination( ), errors=errors, ) - diff --git a/custom_components/climatesync/const.py b/custom_components/climatesync/const.py index 52df846..567cb8d 100644 --- a/custom_components/climatesync/const.py +++ b/custom_components/climatesync/const.py @@ -7,6 +7,7 @@ CONF_IDLE_TEMPERATURE = "idle_temperature" CONF_MAX_SETPOINT = "max_setpoint" CONF_ROUNDING_MODE = "rounding_mode" +CONF_ROUNDING_DIRECTION = "rounding_direction" CONF_RESYNC_INTERVAL = "resync_interval_seconds" CONF_MIN_CHANGE_THRESHOLD = "min_change_threshold" CONF_MIN_SEND_INTERVAL = "min_send_interval_seconds" @@ -24,6 +25,18 @@ ROUNDING_MODES = [ROUNDING_MODE_HALF, ROUNDING_MODE_1DEC, ROUNDING_MODE_2DEC] +ROUNDING_DIRECTION_FLOOR = "floor" +ROUNDING_DIRECTION_NEAREST = "nearest" +ROUNDING_DIRECTION_CEILING = "ceiling" + +ROUNDING_DIRECTIONS = [ + ROUNDING_DIRECTION_FLOOR, + ROUNDING_DIRECTION_NEAREST, + ROUNDING_DIRECTION_CEILING, +] + +DEFAULT_ROUNDING_DIRECTION = ROUNDING_DIRECTION_NEAREST + # Status states for sensor.climatesync_status STATUS_OK = "ok" STATUS_RATE_LIMITED = "rate_limited" diff --git a/custom_components/climatesync/coordinator.py b/custom_components/climatesync/coordinator.py index aa4f2fc..90649ec 100644 --- a/custom_components/climatesync/coordinator.py +++ b/custom_components/climatesync/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import math from datetime import datetime, timedelta from typing import Any @@ -20,6 +21,7 @@ CONF_MIN_CHANGE_THRESHOLD, CONF_MIN_SEND_INTERVAL, CONF_RESYNC_INTERVAL, + CONF_ROUNDING_DIRECTION, CONF_ROUNDING_MODE, CONF_SOURCE_ENTITIES, DEFAULT_IDLE_TEMPERATURE, @@ -27,7 +29,10 @@ DEFAULT_MIN_CHANGE_THRESHOLD, DEFAULT_MIN_SEND_INTERVAL, DEFAULT_RESYNC_INTERVAL, + DEFAULT_ROUNDING_DIRECTION, DEFAULT_ROUNDING_MODE, + ROUNDING_DIRECTION_CEILING, + ROUNDING_DIRECTION_FLOOR, ROUNDING_MODE_2DEC, ROUNDING_MODE_HALF, STATUS_APPLY_FAILED, @@ -39,6 +44,7 @@ ) _LOGGER = logging.getLogger(__name__) +_ROUNDING_EPSILON = 1e-9 def _safe_float(value: Any) -> float | None: @@ -54,8 +60,8 @@ def _safe_float(value: Any) -> float | None: return None -def _apply_rounding(value: float, mode: str) -> float: - """Apply a rounding mode to a temperature value.""" +def _apply_nearest_rounding(value: float, mode: str) -> float: + """Apply the historical nearest rounding mode to a temperature value.""" if mode == ROUNDING_MODE_HALF: return round(value * 2) / 2 if mode == ROUNDING_MODE_2DEC: @@ -64,6 +70,43 @@ def _apply_rounding(value: float, mode: str) -> float: return round(value, 1) +def _rounding_step(mode: str) -> tuple[float, int]: + """Return the quantum and display precision for a rounding mode.""" + if mode == ROUNDING_MODE_HALF: + return 0.5, 1 + if mode == ROUNDING_MODE_2DEC: + return 0.01, 2 + # Default / ROUNDING_MODE_1DEC + return 0.1, 1 + + +def round_setpoint( + value: float, + rounding_mode: str, + rounding_direction: str = DEFAULT_ROUNDING_DIRECTION, +) -> float: + """Round a setpoint using the selected mode and direction.""" + if rounding_direction not in ( + ROUNDING_DIRECTION_FLOOR, + ROUNDING_DIRECTION_CEILING, + ): + return _apply_nearest_rounding(value, rounding_mode) + + step, precision = _rounding_step(rounding_mode) + scaled = value / step + if rounding_direction == ROUNDING_DIRECTION_FLOOR: + rounded = math.floor(scaled + _ROUNDING_EPSILON) * step + else: + rounded = math.ceil(scaled - _ROUNDING_EPSILON) * step + + return round(rounded, precision) + + +def _apply_rounding(value: float, mode: str) -> float: + """Apply the legacy nearest rounding mode to a temperature value.""" + return round_setpoint(value, mode, DEFAULT_ROUNDING_DIRECTION) + + class ClimateSyncCoordinator: """Drives delta-based thermostat synchronisation.""" @@ -78,6 +121,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._idle_temperature: float = DEFAULT_IDLE_TEMPERATURE self._max_setpoint: float = DEFAULT_MAX_SETPOINT self._rounding_mode: str = DEFAULT_ROUNDING_MODE + self._rounding_direction: str = DEFAULT_ROUNDING_DIRECTION self._resync_interval: int = DEFAULT_RESYNC_INTERVAL self._min_change_threshold: float = DEFAULT_MIN_CHANGE_THRESHOLD self._min_send_interval: int = DEFAULT_MIN_SEND_INTERVAL @@ -92,6 +136,8 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.destination_current_target: float | None = None # Computed setpoint + self.raw_setpoint: float | None = None + self.rounded_setpoint: float | None = None self.computed_setpoint: float | None = None # Diagnostics @@ -154,6 +200,10 @@ def async_apply_options(self) -> None: CONF_ROUNDING_MODE, data.get(CONF_ROUNDING_MODE, DEFAULT_ROUNDING_MODE), ) + self._rounding_direction = opts.get( + CONF_ROUNDING_DIRECTION, + data.get(CONF_ROUNDING_DIRECTION, DEFAULT_ROUNDING_DIRECTION), + ) self._resync_interval = int( opts.get(CONF_RESYNC_INTERVAL, DEFAULT_RESYNC_INTERVAL) ) @@ -348,7 +398,14 @@ async def _async_evaluate(self, *, bypass_rate_limit: bool = False) -> None: else: setpoint_raw = dest_current + max_delta - setpoint_final = _apply_rounding(setpoint_raw, self._rounding_mode) + self.raw_setpoint = setpoint_raw + setpoint_rounded = round_setpoint( + setpoint_raw, + self._rounding_mode, + self._rounding_direction, + ) + self.rounded_setpoint = setpoint_rounded + setpoint_final = setpoint_rounded # Clamp to the configured maximum setpoint. This prevents a cascade # where the destination propagates its new setpoint to source TRVs # (e.g. Plugwise Adam), causing ClimateSync to compute an even higher @@ -364,8 +421,13 @@ async def _async_evaluate(self, *, bypass_rate_limit: bool = False) -> None: self.last_desired_setpoint = setpoint_final _LOGGER.debug( - "ClimateSync: computed setpoint=%.2f (max_delta=%.2f, leading=%s)", + "ClimateSync: computed setpoint=%.2f raw=%.2f rounded=%.2f " + "(rounding_mode=%s, rounding_direction=%s, max_delta=%.2f, leading=%s)", setpoint_final, + setpoint_raw, + setpoint_rounded, + self._rounding_mode, + self._rounding_direction, max_delta, leading, ) @@ -545,3 +607,8 @@ def max_setpoint(self) -> float: def rounding_mode(self) -> str: """Return rounding mode.""" return self._rounding_mode + + @property + def rounding_direction(self) -> str: + """Return rounding direction.""" + return self._rounding_direction diff --git a/custom_components/climatesync/sensor.py b/custom_components/climatesync/sensor.py index ef3e1e0..c4b89e2 100644 --- a/custom_components/climatesync/sensor.py +++ b/custom_components/climatesync/sensor.py @@ -219,6 +219,10 @@ def extra_state_attributes(self) -> dict[str, Any]: "destination_current_target": self._coordinator.destination_current_target, "delta_max": round(self._coordinator.delta_max, 2), "rounding_mode": self._coordinator.rounding_mode, + "rounding_direction": self._coordinator.rounding_direction, + "raw_setpoint": self._coordinator.raw_setpoint, + "rounded_setpoint": self._coordinator.rounded_setpoint, + "final_setpoint": self._coordinator.computed_setpoint, "idle_temperature": self._coordinator.idle_temperature, } @@ -296,6 +300,11 @@ def extra_state_attributes(self) -> dict[str, Any]: "last_desired_setpoint": coord.last_desired_setpoint, "last_applied_setpoint": coord.last_applied_setpoint, "current_destination_target": coord.destination_current_target, + "rounding_mode": coord.rounding_mode, + "rounding_direction": coord.rounding_direction, + "raw_setpoint": coord.raw_setpoint, + "rounded_setpoint": coord.rounded_setpoint, + "final_setpoint": coord.computed_setpoint, "mismatch_seconds": round(coord.mismatch_seconds, 1), "mismatch_since": ( coord.mismatch_since.isoformat() if coord.mismatch_since else None diff --git a/custom_components/climatesync/translations/en.json b/custom_components/climatesync/translations/en.json index 39ce994..a97c016 100644 --- a/custom_components/climatesync/translations/en.json +++ b/custom_components/climatesync/translations/en.json @@ -10,12 +10,13 @@ }, "destination": { "title": "ClimateSync – Step 2: Destination & Settings", - "description": "Select the thermostat that ClimateSync will control. The **idle temperature** is used when no room needs heating. The **maximum setpoint** caps how high ClimateSync will ever set the destination (prevents runaway spikes). The **rounding mode** determines how the setpoint is rounded before being sent.", + "description": "Select the thermostat that ClimateSync will control. The **idle temperature** is used when no room needs heating. The **maximum setpoint** caps how high ClimateSync will ever set the destination (prevents runaway spikes). The **rounding mode** determines the step size; the **rounding direction** determines floor, nearest, or ceiling.", "data": { "destination_entity": "Destination thermostat", "idle_temperature": "Idle temperature (°C) – used when no room needs heating", "max_setpoint": "Maximum setpoint (°C) – hard ceiling for the destination setpoint", - "rounding_mode": "Rounding mode for the setpoint" + "rounding_mode": "Rounding mode for the setpoint", + "rounding_direction": "Rounding direction" } } }, @@ -36,12 +37,13 @@ }, "destination": { "title": "ClimateSync – Step 2: Destination & Settings", - "description": "Change the destination thermostat and adjust all settings. **Maximum setpoint**: hard ceiling to prevent runaway spikes. **Resync interval**: how often ClimateSync checks even without changes. **Min change threshold**: smallest setpoint change worth sending. **Min send interval**: minimum seconds between two service calls (rate limiter).", + "description": "Change the destination thermostat and adjust all settings. **Maximum setpoint**: hard ceiling to prevent runaway spikes. **Rounding mode**: step size. **Rounding direction**: floor, nearest, or ceiling. **Resync interval**: how often ClimateSync checks even without changes. **Min change threshold**: smallest setpoint change worth sending. **Min send interval**: minimum seconds between two service calls (rate limiter).", "data": { "destination_entity": "Destination thermostat", "idle_temperature": "Idle temperature (°C) – used when no room needs heating", "max_setpoint": "Maximum setpoint (°C) – hard ceiling for the destination setpoint", "rounding_mode": "Rounding mode for the setpoint", + "rounding_direction": "Rounding direction", "resync_interval_seconds": "Resync interval (seconds)", "min_change_threshold": "Minimum change threshold (°C)", "min_send_interval_seconds": "Minimum send interval (seconds)" @@ -61,6 +63,13 @@ "1_decimal": "1 decimal (e.g. 19.0, 19.3, 19.7)", "2_decimals": "2 decimals (e.g. 19.00, 19.33, 19.67)" } + }, + "rounding_direction": { + "options": { + "floor": "Round floor / down", + "nearest": "Round abs / normal rounding", + "ceiling": "Round ceiling / up" + } } } } diff --git a/custom_components/climatesync/translations/nl.json b/custom_components/climatesync/translations/nl.json index 2c89b03..0906d3b 100644 --- a/custom_components/climatesync/translations/nl.json +++ b/custom_components/climatesync/translations/nl.json @@ -10,12 +10,13 @@ }, "destination": { "title": "ClimateSync – Stap 2: Bestemming & Instellingen", - "description": "Selecteer de thermostaat die ClimateSync bestuurt. De **rusttemperatuur** wordt gebruikt als geen enkele kamer verwarming nodig heeft. Het **maximale setpoint** begrenst hoe hoog ClimateSync de bestemming ooit kan instellen (voorkomt ongewenste pieken). De **afrondingsmodus** bepaalt hoe het setpoint wordt afgerond voordat het wordt verzonden.", + "description": "Selecteer de thermostaat die ClimateSync bestuurt. De **rusttemperatuur** wordt gebruikt als geen enkele kamer verwarming nodig heeft. Het **maximale setpoint** begrenst hoe hoog ClimateSync de bestemming ooit kan instellen (voorkomt ongewenste pieken). De **afrondingsmodus** bepaalt de stapgrootte; de **afrondingsrichting** bepaalt floor, nearest of ceiling.", "data": { "destination_entity": "Bestemmings-thermostaat", "idle_temperature": "Rusttemperatuur (°C) – gebruikt als geen kamer verwarming nodig heeft", "max_setpoint": "Maximaal setpoint (°C) – harde bovengrens voor het bestemmingssetpoint", - "rounding_mode": "Afrondingsmodus voor het setpoint" + "rounding_mode": "Afrondingsmodus voor het setpoint", + "rounding_direction": "Afrondingsrichting" } } }, @@ -36,12 +37,13 @@ }, "destination": { "title": "ClimateSync – Stap 2: Bestemming & Instellingen", - "description": "Wijzig de bestemmings-thermostaat en pas alle instellingen aan. **Maximaal setpoint**: harde bovengrens om ongewenste pieken te voorkomen. **Hersynchronisatie-interval**: hoe vaak ClimateSync controleert ook zonder wijzigingen. **Minimale wijzigingsdrempel**: kleinste setpointwijziging die de moeite waard is om te verzenden. **Minimaal verzendinterval**: minimale seconden tussen twee serviceaanroepen (snelheidsbegrenzer).", + "description": "Wijzig de bestemmings-thermostaat en pas alle instellingen aan. **Maximaal setpoint**: harde bovengrens om ongewenste pieken te voorkomen. **Afrondingsmodus**: stapgrootte. **Afrondingsrichting**: floor, nearest of ceiling. **Hersynchronisatie-interval**: hoe vaak ClimateSync controleert ook zonder wijzigingen. **Minimale wijzigingsdrempel**: kleinste setpointwijziging die de moeite waard is om te verzenden. **Minimaal verzendinterval**: minimale seconden tussen twee serviceaanroepen (snelheidsbegrenzer).", "data": { "destination_entity": "Bestemmings-thermostaat", "idle_temperature": "Rusttemperatuur (°C) – gebruikt als geen kamer verwarming nodig heeft", "max_setpoint": "Maximaal setpoint (°C) – harde bovengrens voor het bestemmingssetpoint", "rounding_mode": "Afrondingsmodus voor het setpoint", + "rounding_direction": "Afrondingsrichting", "resync_interval_seconds": "Hersynchronisatie-interval (seconden)", "min_change_threshold": "Minimale wijzigingsdrempel (°C)", "min_send_interval_seconds": "Minimaal verzendinterval (seconden)" @@ -61,6 +63,13 @@ "1_decimal": "1 decimaal (bijv. 19,0 – 19,3 – 19,7)", "2_decimals": "2 decimalen (bijv. 19,00 – 19,33 – 19,67)" } + }, + "rounding_direction": { + "options": { + "floor": "Afronden floor / omlaag", + "nearest": "Afronden abs / normaal afronden", + "ceiling": "Afronden ceiling / omhoog" + } } } } diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 412241e..5ab36a5 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -44,6 +44,7 @@ CONF_MIN_CHANGE_THRESHOLD, CONF_MIN_SEND_INTERVAL, CONF_RESYNC_INTERVAL, + CONF_ROUNDING_DIRECTION, CONF_ROUNDING_MODE, CONF_SOURCE_ENTITIES, DEFAULT_IDLE_TEMPERATURE, @@ -51,7 +52,14 @@ DEFAULT_MIN_CHANGE_THRESHOLD, DEFAULT_MIN_SEND_INTERVAL, DEFAULT_RESYNC_INTERVAL, + DEFAULT_ROUNDING_DIRECTION, DEFAULT_ROUNDING_MODE, + ROUNDING_DIRECTION_CEILING, + ROUNDING_DIRECTION_FLOOR, + ROUNDING_DIRECTION_NEAREST, + ROUNDING_MODE_1DEC, + ROUNDING_MODE_2DEC, + ROUNDING_MODE_HALF, STATUS_APPLY_FAILED, STATUS_MISMATCH, STATUS_MISSING_SOURCE_DATA, @@ -62,6 +70,7 @@ ClimateSyncCoordinator, _apply_rounding, _safe_float, + round_setpoint, ) # --------------------------------------------------------------------------- @@ -91,6 +100,7 @@ def _build_coordinator( min_send_interval: int = DEFAULT_MIN_SEND_INTERVAL, resync_interval: int = DEFAULT_RESYNC_INTERVAL, rounding_mode: str = DEFAULT_ROUNDING_MODE, + rounding_direction: str | None = None, ) -> tuple[ClimateSyncCoordinator, MagicMock]: """Build a coordinator with a fully-mocked hass/entry, return (coordinator, hass).""" if source_entities is None: @@ -112,6 +122,8 @@ def _build_coordinator( CONF_MIN_SEND_INTERVAL: min_send_interval, CONF_RESYNC_INTERVAL: resync_interval, } + if rounding_direction is not None: + entry.options[CONF_ROUNDING_DIRECTION] = rounding_direction coord = ClimateSyncCoordinator(hass, entry) # Apply config without setting up real HA listeners @@ -120,6 +132,8 @@ def _build_coordinator( coord._idle_temperature = float(idle_temperature) coord._max_setpoint = float(max_setpoint) coord._rounding_mode = rounding_mode + if rounding_direction is not None: + coord._rounding_direction = rounding_direction coord._min_change_threshold = float(min_change_threshold) coord._min_send_interval = int(min_send_interval) coord._resync_interval = int(resync_interval) @@ -174,6 +188,148 @@ def test_2dec(self): assert _apply_rounding(21.346, "2_decimals") == 21.35 +class TestRoundSetpoint: + """Unit tests for round_setpoint helper.""" + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (19.2, 19.0), + (19.3, 19.5), + (19.0, 19.0), + (19.5, 19.5), + ], + ) + def test_half_step_nearest(self, value: float, expected: float): + assert ( + round_setpoint(value, ROUNDING_MODE_HALF, ROUNDING_DIRECTION_NEAREST) + == expected + ) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (19.1, 19.0), + (19.5, 19.5), + (19.9, 19.5), + (19.0, 19.0), + ], + ) + def test_half_step_floor(self, value: float, expected: float): + assert ( + round_setpoint(value, ROUNDING_MODE_HALF, ROUNDING_DIRECTION_FLOOR) + == expected + ) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (19.1, 19.5), + (19.5, 19.5), + (19.6, 20.0), + (19.0, 19.0), + ], + ) + def test_half_step_ceiling(self, value: float, expected: float): + assert ( + round_setpoint(value, ROUNDING_MODE_HALF, ROUNDING_DIRECTION_CEILING) + == expected + ) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (19.14, 19.1), + (19.16, 19.2), + (19.10, 19.1), + ], + ) + def test_1_decimal_nearest(self, value: float, expected: float): + assert ( + round_setpoint(value, ROUNDING_MODE_1DEC, ROUNDING_DIRECTION_NEAREST) + == expected + ) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (19.19, 19.1), + (19.11, 19.1), + (19.10, 19.1), + ], + ) + def test_1_decimal_floor(self, value: float, expected: float): + assert ( + round_setpoint(value, ROUNDING_MODE_1DEC, ROUNDING_DIRECTION_FLOOR) + == expected + ) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (19.11, 19.2), + (19.19, 19.2), + (19.10, 19.1), + ], + ) + def test_1_decimal_ceiling(self, value: float, expected: float): + assert ( + round_setpoint(value, ROUNDING_MODE_1DEC, ROUNDING_DIRECTION_CEILING) + == expected + ) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (19.114, 19.11), + (19.116, 19.12), + (19.10, 19.1), + ], + ) + def test_2_decimals_nearest(self, value: float, expected: float): + assert ( + round_setpoint(value, ROUNDING_MODE_2DEC, ROUNDING_DIRECTION_NEAREST) + == expected + ) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (19.119, 19.11), + (19.111, 19.11), + (19.10, 19.1), + ], + ) + def test_2_decimals_floor(self, value: float, expected: float): + assert ( + round_setpoint(value, ROUNDING_MODE_2DEC, ROUNDING_DIRECTION_FLOOR) + == expected + ) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + (19.111, 19.12), + (19.119, 19.12), + (19.10, 19.1), + ], + ) + def test_2_decimals_ceiling(self, value: float, expected: float): + assert ( + round_setpoint(value, ROUNDING_MODE_2DEC, ROUNDING_DIRECTION_CEILING) + == expected + ) + + def test_missing_rounding_direction_defaults_to_nearest(self): + coord, _ = _build_coordinator(rounding_mode=ROUNDING_MODE_HALF) + + coord.async_apply_options() + + assert coord.rounding_direction == DEFAULT_ROUNDING_DIRECTION + assert coord.rounding_direction == ROUNDING_DIRECTION_NEAREST + assert round_setpoint(19.2, coord.rounding_mode, coord.rounding_direction) == 19.0 + + # --------------------------------------------------------------------------- # Core coordinator tests # --------------------------------------------------------------------------- @@ -466,6 +622,32 @@ async def test_idle_temperature_when_no_demand(): assert coord.delta_max == 0.0 +@pytest.mark.asyncio +async def test_rounding_direction_applies_to_raw_setpoint_not_delta(): + """Rounding direction is applied after destination_current + delta_max.""" + coord, hass = _build_coordinator( + min_change_threshold=0.0, + rounding_mode=ROUNDING_MODE_HALF, + rounding_direction=ROUNDING_DIRECTION_CEILING, + ) + + # delta=0.1 and destination_current=19.1 produce raw=19.2. + # Ceiling raw 19.2 to half steps gives 19.5. If the delta were rounded + # separately first, this would become 20.0, which is not desired. + _configure_states(hass, { + "climate.room1": _make_state(current_temperature=20.0, target_temperature=20.1), + "climate.dest": _make_state(current_temperature=19.1, target_temperature=5.0), + }) + + await coord._async_evaluate() + + assert coord.raw_setpoint == pytest.approx(19.2) + assert coord.rounded_setpoint == 19.5 + assert coord.computed_setpoint == 19.5 + call_args = hass.services.async_call.call_args + assert call_args[0][2]["temperature"] == 19.5 + + # --------------------------------------------------------------------------- # State-change filtering tests # --------------------------------------------------------------------------- diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 8927f09..9ade998 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -82,6 +82,7 @@ class _SensorEntity: DEFAULT_MIN_CHANGE_THRESHOLD, DEFAULT_MIN_SEND_INTERVAL, DEFAULT_RESYNC_INTERVAL, + DEFAULT_ROUNDING_DIRECTION, DEFAULT_ROUNDING_MODE, ) from custom_components.climatesync.coordinator import ( # noqa: E402 @@ -133,6 +134,7 @@ def _build_coordinator( coord._destination_entity = destination_entity coord._idle_temperature = float(DEFAULT_IDLE_TEMPERATURE) coord._rounding_mode = DEFAULT_ROUNDING_MODE + coord._rounding_direction = DEFAULT_ROUNDING_DIRECTION return coord, hass @@ -221,6 +223,49 @@ def test_sort_order_is_correct(self): assert names == sorted(names) +# --------------------------------------------------------------------------- +# Tests: setpoint diagnostics +# --------------------------------------------------------------------------- + + +class TestSetpointDiagnostics: + """Verify rounding diagnostics are exposed as sensor attributes.""" + + def test_destination_setpoint_attributes_include_rounding_context(self): + coord, _ = _build_coordinator(destination_entity="climate.emma") + coord.destination_current_temperature = 19.1 + coord.destination_current_target = 19.0 + coord.delta_max = 0.1 + coord.raw_setpoint = 19.2 + coord.rounded_setpoint = 19.5 + coord.computed_setpoint = 19.5 + + sensor = DestinationSetpointSensor(coord, coord.entry, _make_device_info()) + attrs = sensor.extra_state_attributes + + assert attrs["destination_entity_id"] == "climate.emma" + assert attrs["rounding_mode"] == DEFAULT_ROUNDING_MODE + assert attrs["rounding_direction"] == DEFAULT_ROUNDING_DIRECTION + assert attrs["raw_setpoint"] == 19.2 + assert attrs["rounded_setpoint"] == 19.5 + assert attrs["final_setpoint"] == 19.5 + + def test_status_attributes_include_rounding_context(self): + coord, _ = _build_coordinator() + coord.raw_setpoint = 19.2 + coord.rounded_setpoint = 19.5 + coord.computed_setpoint = 19.5 + + sensor = StatusSensor(coord, coord.entry, _make_device_info()) + attrs = sensor.extra_state_attributes + + assert attrs["rounding_mode"] == DEFAULT_ROUNDING_MODE + assert attrs["rounding_direction"] == DEFAULT_ROUNDING_DIRECTION + assert attrs["raw_setpoint"] == 19.2 + assert attrs["rounded_setpoint"] == 19.5 + assert attrs["final_setpoint"] == 19.5 + + # --------------------------------------------------------------------------- # Tests: DestinationCurrentTargetSensor # ---------------------------------------------------------------------------