diff --git a/README.md b/README.md index 531afe5..185d881 100644 --- a/README.md +++ b/README.md @@ -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? @@ -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`. diff --git a/custom_components/climatesync/brand/icon.png b/custom_components/climatesync/brand/icon.png new file mode 100644 index 0000000..bcf7a25 Binary files /dev/null and b/custom_components/climatesync/brand/icon.png differ diff --git a/custom_components/climatesync/config_flow.py b/custom_components/climatesync/config_flow.py index 965454f..1cacf8b 100644 --- a/custom_components/climatesync/config_flow.py +++ b/custom_components/climatesync/config_flow.py @@ -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) # ────────────────────────────────────────────────────────────────────────────── @@ -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) ), }, ) @@ -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], @@ -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( diff --git a/custom_components/climatesync/const.py b/custom_components/climatesync/const.py index 567cb8d..52438af 100644 --- a/custom_components/climatesync/const.py +++ b/custom_components/climatesync/const.py @@ -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 diff --git a/custom_components/climatesync/coordinator.py b/custom_components/climatesync/coordinator.py index 90649ec..1eda6bd 100644 --- a/custom_components/climatesync/coordinator.py +++ b/custom_components/climatesync/coordinator.py @@ -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, @@ -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: @@ -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) @@ -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) diff --git a/custom_components/climatesync/manifest.json b/custom_components/climatesync/manifest.json index e610bc6..08d1af1 100644 --- a/custom_components/climatesync/manifest.json +++ b/custom_components/climatesync/manifest.json @@ -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" } diff --git a/custom_components/climatesync/sensor.py b/custom_components/climatesync/sensor.py index c4b89e2..5cdbddf 100644 --- a/custom_components/climatesync/sensor.py +++ b/custom_components/climatesync/sensor.py @@ -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, @@ -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, } @@ -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, @@ -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": ( diff --git a/custom_components/climatesync/translations/en.json b/custom_components/climatesync/translations/en.json index a97c016..63a95c2 100644 --- a/custom_components/climatesync/translations/en.json +++ b/custom_components/climatesync/translations/en.json @@ -67,7 +67,7 @@ "rounding_direction": { "options": { "floor": "Round floor / down", - "nearest": "Round abs / normal rounding", + "nearest": "Round nearest (default)", "ceiling": "Round ceiling / up" } } diff --git a/custom_components/climatesync/translations/nl.json b/custom_components/climatesync/translations/nl.json index 0906d3b..31de30b 100644 --- a/custom_components/climatesync/translations/nl.json +++ b/custom_components/climatesync/translations/nl.json @@ -67,7 +67,7 @@ "rounding_direction": { "options": { "floor": "Afronden floor / omlaag", - "nearest": "Afronden abs / normaal afronden", + "nearest": "Afronden nearest (standaard)", "ceiling": "Afronden ceiling / omhoog" } } diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 5ab36a5..9245cfa 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -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.""" @@ -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): @@ -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): @@ -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): @@ -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 diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 9ade998..057bfc1 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -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