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
52 changes: 45 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -104,21 +108,46 @@ 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 |
|---|---|---|
| `0.5 steps` | 19.3 | 19.5 |
| `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
Expand All @@ -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`

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

Expand Down
30 changes: 29 additions & 1 deletion custom_components/climatesync/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@
CONF_MIN_CHANGE_THRESHOLD,
CONF_MIN_SEND_INTERVAL,
CONF_RESYNC_INTERVAL,
CONF_ROUNDING_DIRECTION,
CONF_ROUNDING_MODE,
CONF_SOURCE_ENTITIES,
DEFAULT_IDLE_TEMPERATURE,
DEFAULT_MAX_SETPOINT,
DEFAULT_MIN_CHANGE_THRESHOLD,
DEFAULT_MIN_SEND_INTERVAL,
DEFAULT_RESYNC_INTERVAL,
DEFAULT_ROUNDING_DIRECTION,
DEFAULT_ROUNDING_MODE,
DOMAIN,
ROUNDING_DIRECTIONS,
ROUNDING_MODES,
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
),
},
)

Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand All @@ -301,4 +330,3 @@ async def async_step_destination(
),
errors=errors,
)

13 changes: 13 additions & 0 deletions custom_components/climatesync/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
75 changes: 71 additions & 4 deletions custom_components/climatesync/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import logging
import math
from datetime import datetime, timedelta
from typing import Any

Expand All @@ -20,14 +21,18 @@
CONF_MIN_CHANGE_THRESHOLD,
CONF_MIN_SEND_INTERVAL,
CONF_RESYNC_INTERVAL,
CONF_ROUNDING_DIRECTION,
CONF_ROUNDING_MODE,
CONF_SOURCE_ENTITIES,
DEFAULT_IDLE_TEMPERATURE,
DEFAULT_MAX_SETPOINT,
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,
Expand All @@ -39,6 +44,7 @@
)

_LOGGER = logging.getLogger(__name__)
_ROUNDING_EPSILON = 1e-9


def _safe_float(value: Any) -> float | None:
Expand All @@ -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:
Expand All @@ -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."""

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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
Loading
Loading