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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@

> **Developed with Plugwise Emma in mind, but universally usable with any Home Assistant climate entity that exposes `current_temperature` and `temperature` attributes.**

## Recovery release 1.2.2

This release is intentionally built from the stable `v1.2.1` baseline and only includes the rounding improvements from PR #8 (`rounding_direction`).
Unwanted functionality introduced in newer lines is intentionally excluded from this recovery patch.

ClimateSync is a HACS-ready Home Assistant custom integration that implements **delta-based thermostat synchronisation**. It reads the heating demand (delta between target and current temperature) from multiple source climate entities (rooms) and drives a single destination thermostat by continuously adjusting its target temperature.

### Why delta-based instead of copying the highest setpoint?
Expand Down Expand Up @@ -119,6 +124,7 @@ If abs(destination_current_target - setpoint_final) > min_change_threshold:
### Rounding mode and direction

The **rounding mode** determines the step size. The **rounding direction** determines where the raw setpoint lands within that step size.
ClimateSync applies epsilon-safe floor/nearest/ceiling rounding to avoid floating-point edge cases around step boundaries.

`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`.

Expand Down
Binary file added custom_components/climatesync/brand/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 13 additions & 8 deletions custom_components/climatesync/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ def _destination_schema(
return vol.Schema(fields)


def _normalize_rounding_direction(value: Any) -> str:
"""Return a safe rounding direction value for storage/use."""
if value in ROUNDING_DIRECTIONS:
return value
return DEFAULT_ROUNDING_DIRECTION


# ──────────────────────────────────────────────────────────────────────────────
# Initial config flow (2 steps, no advanced options)
# ──────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -217,9 +224,8 @@ 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_ROUNDING_DIRECTION: _normalize_rounding_direction(
user_input.get(CONF_ROUNDING_DIRECTION)
),
},
)
Expand Down Expand Up @@ -299,9 +305,8 @@ 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_ROUNDING_DIRECTION: _normalize_rounding_direction(
user_input.get(CONF_ROUNDING_DIRECTION)
),
CONF_RESYNC_INTERVAL: user_input[CONF_RESYNC_INTERVAL],
CONF_MIN_CHANGE_THRESHOLD: user_input[CONF_MIN_CHANGE_THRESHOLD],
Expand All @@ -316,8 +321,8 @@ 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_rounding_direction=_normalize_rounding_direction(
self._get(CONF_ROUNDING_DIRECTION, DEFAULT_ROUNDING_DIRECTION)
),
default_resync=self._get(CONF_RESYNC_INTERVAL, DEFAULT_RESYNC_INTERVAL),
default_threshold=self._get(
Expand Down
6 changes: 3 additions & 3 deletions custom_components/climatesync/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@
ROUNDING_MODE_1DEC = "1_decimal"
ROUNDING_MODE_2DEC = "2_decimals"

ROUNDING_MODES = [ROUNDING_MODE_HALF, ROUNDING_MODE_1DEC, ROUNDING_MODE_2DEC]
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_DIRECTIONS = (
ROUNDING_DIRECTION_FLOOR,
ROUNDING_DIRECTION_NEAREST,
ROUNDING_DIRECTION_CEILING,
]
)

DEFAULT_ROUNDING_DIRECTION = ROUNDING_DIRECTION_NEAREST

Expand Down
65 changes: 41 additions & 24 deletions custom_components/climatesync/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
DEFAULT_ROUNDING_MODE,
ROUNDING_DIRECTION_CEILING,
ROUNDING_DIRECTION_FLOOR,
ROUNDING_DIRECTIONS,
ROUNDING_MODES,
ROUNDING_MODE_2DEC,
ROUNDING_MODE_HALF,
STATUS_APPLY_FAILED,
Expand Down Expand Up @@ -60,16 +62,6 @@ def _safe_float(value: Any) -> float | None:
return None


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:
return round(value, 2)
# Default / ROUNDING_MODE_1DEC
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:
Expand All @@ -80,24 +72,46 @@ def _rounding_step(mode: str) -> tuple[float, int]:
return 0.1, 1


def _normalize_rounding_mode(mode: str) -> str:
"""Return a safe rounding mode."""
if mode in ROUNDING_MODES:
return mode
return DEFAULT_ROUNDING_MODE


def _normalize_rounding_direction(direction: str) -> str:
"""Return a safe rounding direction."""
if direction in ROUNDING_DIRECTIONS:
return direction
return DEFAULT_ROUNDING_DIRECTION


def _round_scaled(scaled: float, direction: str) -> float:
"""Round a scaled value using epsilon-safe floor/nearest/ceiling logic.

Floor uses +epsilon for values slightly below boundaries; ceiling uses
-epsilon for values slightly above boundaries, so float artifacts still
land on the expected step. Nearest intentionally uses plain Python
round() to preserve the legacy v1.2.1 behavior for existing configs.
"""
if direction == ROUNDING_DIRECTION_FLOOR:
return float(math.floor(scaled + _ROUNDING_EPSILON))
if direction == ROUNDING_DIRECTION_CEILING:
return float(math.ceil(scaled - _ROUNDING_EPSILON))
return float(round(scaled))


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)
mode = _normalize_rounding_mode(rounding_mode)
direction = _normalize_rounding_direction(rounding_direction)
step, precision = _rounding_step(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
rounded = _round_scaled(scaled, direction) * step

return round(rounded, precision)

Expand Down Expand Up @@ -200,9 +214,12 @@ 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._rounding_mode = _normalize_rounding_mode(self._rounding_mode)
self._rounding_direction = _normalize_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
10 changes: 5 additions & 5 deletions custom_components/climatesync/manifest.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"domain": "climatesync",
"name": "ClimateSync",
"version": "1.2.0",
"documentation": "https://github.com/Patrick1610/ClimateSync",
"issue_tracker": "https://github.com/Patrick1610/ClimateSync/issues",
"codeowners": ["@Patrick1610"],
"requirements": [],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/Patrick1610/ClimateSync",
"iot_class": "local_push",
"config_flow": true
"issue_tracker": "https://github.com/Patrick1610/ClimateSync/issues",
"requirements": [],
"version": "1.2.2"
}
10 changes: 8 additions & 2 deletions custom_components/climatesync/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ def native_value(self) -> float | None:
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return detailed setpoint context."""
rounded_setpoint = self._coordinator.rounded_setpoint
if rounded_setpoint is None:
rounded_setpoint = self._coordinator.computed_setpoint
return {
"destination_entity_id": self._coordinator.destination_entity,
"destination_current_temperature": self._coordinator.destination_current_temperature,
Expand All @@ -221,7 +224,7 @@ def extra_state_attributes(self) -> dict[str, Any]:
"rounding_mode": self._coordinator.rounding_mode,
"rounding_direction": self._coordinator.rounding_direction,
"raw_setpoint": self._coordinator.raw_setpoint,
"rounded_setpoint": self._coordinator.rounded_setpoint,
"rounded_setpoint": rounded_setpoint,
"final_setpoint": self._coordinator.computed_setpoint,
"idle_temperature": self._coordinator.idle_temperature,
}
Expand Down Expand Up @@ -295,6 +298,7 @@ def extra_state_attributes(self) -> dict[str, Any]:
else None
)
return {
"destination_entity_id": coord.destination_entity,
"last_update_time": last_update,
"last_service_call_time": last_call,
"last_desired_setpoint": coord.last_desired_setpoint,
Expand All @@ -303,7 +307,9 @@ def extra_state_attributes(self) -> dict[str, Any]:
"rounding_mode": coord.rounding_mode,
"rounding_direction": coord.rounding_direction,
"raw_setpoint": coord.raw_setpoint,
"rounded_setpoint": coord.rounded_setpoint,
"rounded_setpoint": coord.rounded_setpoint
if coord.rounded_setpoint is not None
else coord.computed_setpoint,
"final_setpoint": coord.computed_setpoint,
"mismatch_seconds": round(coord.mismatch_seconds, 1),
"mismatch_since": (
Expand Down
2 changes: 1 addition & 1 deletion custom_components/climatesync/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
"rounding_direction": {
"options": {
"floor": "Round floor / down",
"nearest": "Round abs / normal rounding",
"nearest": "Round nearest (default)",
"ceiling": "Round ceiling / up"
}
}
Expand Down
2 changes: 1 addition & 1 deletion custom_components/climatesync/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
"rounding_direction": {
"options": {
"floor": "Afronden floor / omlaag",
"nearest": "Afronden abs / normaal afronden",
"nearest": "Afronden nearest (standaard)",
"ceiling": "Afronden ceiling / omhoog"
}
}
Expand Down
39 changes: 39 additions & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,20 @@ def test_1dec(self):
def test_2dec(self):
assert _apply_rounding(21.346, "2_decimals") == 21.35

@pytest.mark.parametrize(
("value", "mode", "expected"),
[
(19.25, ROUNDING_MODE_HALF, 19.0),
(20.25, ROUNDING_MODE_HALF, 20.0),
(19.15, ROUNDING_MODE_1DEC, 19.1),
(19.25, ROUNDING_MODE_1DEC, 19.2),
(19.005, ROUNDING_MODE_2DEC, 19.0),
(19.025, ROUNDING_MODE_2DEC, 19.02),
],
)
def test_legacy_nearest_tie_cases(self, value: float, mode: str, expected: float):
assert _apply_rounding(value, mode) == expected


class TestRoundSetpoint:
"""Unit tests for round_setpoint helper."""
Expand All @@ -198,6 +212,8 @@ class TestRoundSetpoint:
(19.3, 19.5),
(19.0, 19.0),
(19.5, 19.5),
(19.25, 19.0),
(20.25, 20.0),
],
)
def test_half_step_nearest(self, value: float, expected: float):
Expand Down Expand Up @@ -242,6 +258,8 @@ def test_half_step_ceiling(self, value: float, expected: float):
(19.14, 19.1),
(19.16, 19.2),
(19.10, 19.1),
(19.15, 19.1),
(19.25, 19.2),
],
)
def test_1_decimal_nearest(self, value: float, expected: float):
Expand Down Expand Up @@ -284,6 +302,8 @@ def test_1_decimal_ceiling(self, value: float, expected: float):
(19.114, 19.11),
(19.116, 19.12),
(19.10, 19.1),
(19.005, 19.0),
(19.025, 19.02),
],
)
def test_2_decimals_nearest(self, value: float, expected: float):
Expand Down Expand Up @@ -329,6 +349,25 @@ def test_missing_rounding_direction_defaults_to_nearest(self):
assert coord.rounding_direction == ROUNDING_DIRECTION_NEAREST
assert round_setpoint(19.2, coord.rounding_mode, coord.rounding_direction) == 19.0

def test_invalid_rounding_direction_falls_back_to_nearest(self):
assert round_setpoint(19.3, ROUNDING_MODE_HALF, "invalid") == 19.5

def test_invalid_rounding_mode_falls_back_to_default(self):
assert round_setpoint(19.26, "invalid_mode", ROUNDING_DIRECTION_FLOOR) == 19.2

def test_epsilon_safe_floor_and_ceiling(self):
assert round_setpoint(19.5000000001, ROUNDING_MODE_HALF, ROUNDING_DIRECTION_FLOOR) == 19.5
assert round_setpoint(19.4999999999, ROUNDING_MODE_HALF, ROUNDING_DIRECTION_CEILING) == 19.5


def test_apply_options_invalid_rounding_direction_defaults_to_nearest():
coord, _ = _build_coordinator(rounding_mode=ROUNDING_MODE_HALF)
coord.entry.options[CONF_ROUNDING_DIRECTION] = "bad_value"

coord.async_apply_options()

assert coord.rounding_direction == DEFAULT_ROUNDING_DIRECTION


# ---------------------------------------------------------------------------
# Core coordinator tests
Expand Down
13 changes: 13 additions & 0 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,25 @@ def test_status_attributes_include_rounding_context(self):
sensor = StatusSensor(coord, coord.entry, _make_device_info())
attrs = sensor.extra_state_attributes

assert attrs["destination_entity_id"] == "climate.dest"
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_rounding_attributes_fallback_to_final_setpoint(self):
coord, _ = _build_coordinator()
coord.raw_setpoint = 19.2
coord.rounded_setpoint = None
coord.computed_setpoint = 19.5

setpoint_sensor = DestinationSetpointSensor(coord, coord.entry, _make_device_info())
status_sensor = StatusSensor(coord, coord.entry, _make_device_info())

assert setpoint_sensor.extra_state_attributes["rounded_setpoint"] == 19.5
assert status_sensor.extra_state_attributes["rounded_setpoint"] == 19.5


# ---------------------------------------------------------------------------
# Tests: DestinationCurrentTargetSensor
Expand Down
Loading