From ff76dd646c9322ba1420c086ac56b24ae7013faa Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 17 Jun 2025 12:45:52 +0200 Subject: [PATCH 01/16] add starting output --- custom_components/simple_pid_controller/number.py | 14 ++++++++++++++ custom_components/simple_pid_controller/sensor.py | 8 ++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 7b02619..aa25a8d 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -96,6 +96,14 @@ "default": 1, "entity_category": EntityCategory.CONFIG, }, + { + "name": "Starting Output", + "key": "starting_output", + "unit": "", + "step": 1.0, + "default": 0.0, + "entity_category": EntityCategory.CONFIG, + }, ] @@ -181,6 +189,8 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None: if self._key == "setpoint": min_val, max_val = input_range_min, input_range_max + if self._key == "starting_output": + min_val, max_val = output_range_min, output_range_max elif self._key == "output_min": min_val, max_val = output_range_min, output_range_max elif self._key == "output_max": @@ -205,6 +215,10 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None: self._attr_native_value = input_range_min + ( input_range_max - input_range_min ) * float(desc["default"]) + elif self._key == "starting_output": + self._attr_native_value = output_range_min + ( + output_range_max - output_range_min + ) * float(desc["default"]) elif self._key == "output_min": self._attr_native_value = output_range_min elif self._key == "output_max": diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 4f26ec7..0bd165c 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -34,9 +34,12 @@ async def async_setup_entry( handle: PIDDeviceHandle = entry.runtime_data.handle # Init PID with default values - pid = PID(1.0, 0.1, 0.05, setpoint=50) + setpoint = handle.get_number("setpoint") + starting_output = handle.get_number("starting_output") + + pid = PID(1.0, 0.0, 0.0, setpoint=setpoint, starting_output=starting_output) pid.sample_time = 10.0 - pid.output_limits = (-10.0, 10.0) + pid.output_limits = (0.0, 100.0) async def update_pid(): """Update the PID output using current sensor and parameter values.""" @@ -59,6 +62,7 @@ async def update_pid(): # adapt PID settings pid.tunings = (kp, ki, kd) pid.setpoint = setpoint + pid.sample_time = sample_time if windup_protection: pid.output_limits = (out_min, out_max) From 408200e87ebe8db8be4fef57cf235079d98acbfe Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 17 Jun 2025 11:48:33 +0000 Subject: [PATCH 02/16] Change init pattern Adapt tests --- .../simple_pid_controller/number.py | 4 ++-- .../simple_pid_controller/sensor.py | 16 ++++++++++------ tests/test_sensor.py | 5 +++-- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index aa25a8d..06f53f9 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -103,7 +103,7 @@ "step": 1.0, "default": 0.0, "entity_category": EntityCategory.CONFIG, - }, + }, ] @@ -189,7 +189,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None: if self._key == "setpoint": min_val, max_val = input_range_min, input_range_max - if self._key == "starting_output": + elif self._key == "starting_output": min_val, max_val = output_range_min, output_range_max elif self._key == "output_min": min_val, max_val = output_range_min, output_range_max diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 0bd165c..0bb056d 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -32,12 +32,9 @@ async def async_setup_entry( ) -> None: """Set up PID output and diagnostic sensors.""" handle: PIDDeviceHandle = entry.runtime_data.handle + handle.init_phase = True - # Init PID with default values - setpoint = handle.get_number("setpoint") - starting_output = handle.get_number("starting_output") - - pid = PID(1.0, 0.0, 0.0, setpoint=setpoint, starting_output=starting_output) + pid = PID(1.0, 0.0, 0.0, setpoint=0, auto_mode=False) pid.sample_time = 10.0 pid.output_limits = (0.0, 100.0) @@ -52,6 +49,7 @@ async def update_pid(): ki = handle.get_number("ki") kd = handle.get_number("kd") setpoint = handle.get_number("setpoint") + starting_output = handle.get_number("starting_output") sample_time = handle.get_number("sample_time") out_min = handle.get_number("output_min") out_max = handle.get_number("output_max") @@ -68,7 +66,13 @@ async def update_pid(): pid.output_limits = (out_min, out_max) else: pid.output_limits = (None, None) - pid.auto_mode = auto_mode + + if handle.init_phase: + handle.init_phase = False + pid.set_auto_mode(True, last_output=starting_output) + else: + pid.auto_mode = auto_mode + pid.proportional_on_measurement = p_on_m output = pid(input_value) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 509afaa..5298829 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -20,6 +20,7 @@ async def test_pid_output_and_contributions_update(hass, config_entry): "ki": 0.1, "kd": 0.01, "setpoint": 20.0, + "starting_output": 50.0, "sample_time": sample_time, "output_min": 0.0, "output_max": 100.0, @@ -151,7 +152,7 @@ async def test_update_pid_output_limits_none_when_windup_protection_disabled( # Dummy PID to inspect output_limits class DummyPID: - def __init__(self, kp, ki, kd, setpoint): + def __init__(self, kp, ki, kd, setpoint, auto_mode): self._proportional = 1.0 self._integral = 1.0 self._last_output = None @@ -159,7 +160,7 @@ def __init__(self, kp, ki, kd, setpoint): self.setpoint = setpoint self.sample_time = 1.0 self.output_limits = (0.0, 0.0) - self.auto_mode = True + self.auto_mode = auto_mode self.proportional_on_measurement = False def __call__(self, input_value): From 711acf9c2c95bfd604dbd1c38c12fb427cf43355 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 18 Jun 2025 10:01:43 +0000 Subject: [PATCH 03/16] Changes to be committed: new file: custom_components/simple_pid_controller/select.py modified: custom_components/simple_pid_controller/sensor.py --- .../simple_pid_controller/select.py | 42 +++++++++++++++++++ .../simple_pid_controller/sensor.py | 16 ++++++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 custom_components/simple_pid_controller/select.py diff --git a/custom_components/simple_pid_controller/select.py b/custom_components/simple_pid_controller/select.py new file mode 100644 index 0000000..534caf7 --- /dev/null +++ b/custom_components/simple_pid_controller/select.py @@ -0,0 +1,42 @@ +from homeassistant.components.select import SelectEntity +from homeassistant.helpers.restore_state import RestoreEntity + +from .entity import BasePIDEntity + +START_MODE_OPTIONS = [ + "Zero start", # Simple and safe, but may cause jumps + "Last known value", # Continuous, smooth resumption + "Startup value", # User-defined default at startup +] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the PID start mode select entity.""" + coordinator = entry.runtime_data.coordinator + async_add_entities( + [PIDStartModeSelect(hass, entry, "start_mode", "PID Start Mode", coordinator)] + ) + + +class PIDStartModeSelect(BasePIDEntity, SelectEntity, RestoreEntity): + """Representation of the PID start mode selection.""" + + def __init__(self, hass, entry, key, name, coordinator): + super().__init__(hass, entry, key, name) + self._attr_options = START_MODE_OPTIONS + self._attr_current_option = START_MODE_OPTIONS[0] + self.coordinator = coordinator # if needed later + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + if option in self._attr_options: + self._attr_current_option = option + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Restore previous state.""" + await super().async_added_to_hass() + if ( + last_state := await self.async_get_last_state() + ) and last_state.state in self._attr_options: + self._attr_current_option = last_state.state diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 0bb056d..16e73f6 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -10,6 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.restore_state import RestoreEntity from datetime import timedelta from simple_pid import PID @@ -69,6 +70,7 @@ async def update_pid(): if handle.init_phase: handle.init_phase = False + pid.set_auto_mode(True, last_output=handle.last_known_output) pid.set_auto_mode(True, last_output=starting_output) else: pid.auto_mode = auto_mode @@ -165,7 +167,9 @@ def _listener(event): ) -class PIDOutputSensor(CoordinatorEntity[PIDDataCoordinator], SensorEntity): +class PIDOutputSensor( + CoordinatorEntity[PIDDataCoordinator], RestoreEntity, SensorEntity +): """Sensor representing the PID output.""" def __init__( @@ -180,6 +184,16 @@ def __init__( self._attr_native_unit_of_measurement = "%" self._attr_state_class = SensorStateClass.MEASUREMENT + self._handle = entry.runtime_data.handle + + async def async_added_to_hass(self): + await super().async_added_to_hass() + if (state := await self.async_get_last_state()) is not None: + try: + value = float(state.state) + self._handle.last_known_output = value + except (ValueError, TypeError): + self._handle.last_known_output = 0.0 @property def native_value(self) -> float | None: From cb2053def0cf5bd41e61d0f1bf6da7ff52363b8d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 20 Jun 2025 05:18:01 +0000 Subject: [PATCH 04/16] Created alpha version of restore functionality of output --- .../simple_pid_controller/__init__.py | 23 +++++++++++++- .../simple_pid_controller/sensor.py | 31 +++++++++++++++---- requirements.txt | 2 +- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 6d8826c..e9b7d8f 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -27,7 +27,12 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.SENSOR, + Platform.NUMBER, + Platform.SWITCH, + Platform.SELECT, +] @dataclass @@ -89,6 +94,22 @@ def get_number(self, key: str) -> float | None: ) return None + def get_select(self, key: str) -> float | None: + """Return the current value of the number entity, or None.""" + entity_id = self._get_entity_id("select", key) + if not entity_id: + return None + state = self.hass.states.get(entity_id) + _LOGGER.debug("get_select(%s) → %s = %s", key, entity_id, state and state.state) + if state and state.state not in ("unknown", "unavailable"): + try: + return state.state + except ValueError: + _LOGGER.error( + "Could not parse state '%s' of %s", state.state, entity_id + ) + return None + def get_switch(self, key: str) -> bool: """Return True/False of switch entity, default True if missing.""" entity_id = self._get_entity_id("switch", key) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 3871f88..e8d2d20 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -40,6 +40,7 @@ async def async_setup_entry( pid.output_limits = (-10.0, 10.0) handle.last_contributions = (0, 0, 0, 0) + handle.last_known_output = None async def update_pid(): """Update the PID output using current sensor and parameter values.""" @@ -53,6 +54,7 @@ async def update_pid(): kd = handle.get_number("kd") setpoint = handle.get_number("setpoint") starting_output = handle.get_number("starting_output") + start_mode = handle.get_select("start_mode") sample_time = handle.get_number("sample_time") out_min = handle.get_number("output_min") out_max = handle.get_number("output_max") @@ -70,17 +72,34 @@ async def update_pid(): else: pid.output_limits = (None, None) - if handle.init_phase: + if (handle.init_phase and auto_mode) or (not pid.auto_mode and auto_mode): handle.init_phase = False - pid.set_auto_mode(True, last_output=handle.last_known_output) - pid.set_auto_mode(True, last_output=starting_output) + if start_mode == "Zero start": + pid.set_auto_mode(True, 0) + elif start_mode == "Last known value": + pid.set_auto_mode(True, last_output=handle.last_known_output) + elif start_mode == "Startup value": + pid.set_auto_mode(True, last_output=starting_output) + else: + pid.set_auto_mode(True) else: pid.auto_mode = auto_mode + handle.init_phase = False + + # if handle.init_phase: + # handle.init_phase = False + # #pid.set_auto_mode(True, last_output=handle.last_known_output) + # pid.set_auto_mode(True, last_output=starting_output) + # else: + # pid.auto_mode = auto_mode pid.proportional_on_measurement = p_on_m output = pid(input_value) + # save last know output + handle.last_known_output = output + # save last I contribution last_i = handle.last_contributions[1] @@ -192,16 +211,16 @@ def __init__( self._attr_native_unit_of_measurement = "%" self._attr_state_class = SensorStateClass.MEASUREMENT - self._handle = entry.runtime_data.handle + self.handle = entry.runtime_data.handle async def async_added_to_hass(self): await super().async_added_to_hass() if (state := await self.async_get_last_state()) is not None: try: value = float(state.state) - self._handle.last_known_output = value + self.handle.last_known_output = value except (ValueError, TypeError): - self._handle.last_known_output = 0.0 + self.handle.last_known_output = 0.0 @property def native_value(self) -> float | None: diff --git a/requirements.txt b/requirements.txt index 740daba..c9f53af 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ asyncio -pytest +pytest==8.3.5 pytest-cov pytest-homeassistant-custom-component simple-pid==2.0.1 From 5cb75a74cd4565dc7808898e0e3a33c0116229a3 Mon Sep 17 00:00:00 2001 From: Anssi Hannula Date: Fri, 20 Jun 2025 18:01:17 +0300 Subject: [PATCH 05/16] Allow specifying gain factors lower than 0.01 In some cases factors less than 0.01 are useful to make the PID response slower. Reduce the minimum step to 0.001. --- custom_components/simple_pid_controller/number.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 7b02619..2e57791 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -35,7 +35,7 @@ "unit": "", "min": -10.0, "max": 10.0, - "step": 0.01, + "step": 0.001, "default": 1.0, "entity_category": EntityCategory.CONFIG, }, @@ -45,7 +45,7 @@ "unit": "", "min": -10.0, "max": 10.0, - "step": 0.01, + "step": 0.001, "default": 0.1, "entity_category": EntityCategory.CONFIG, }, @@ -55,7 +55,7 @@ "unit": "", "min": -10.0, "max": 10.0, - "step": 0.01, + "step": 0.001, "default": 0.05, "entity_category": EntityCategory.CONFIG, }, From 33cdd478889b790a59baf979524eb403afeaac52 Mon Sep 17 00:00:00 2001 From: Anssi Hannula Date: Fri, 20 Jun 2025 17:17:57 +0300 Subject: [PATCH 06/16] Fix the PID controller to follow the set sample time We give out the intended sample time out to simple_pid, which uses it to filter out calls that happen too often. However, we often call simple_pid a fraction of a second sooner than it expects, causing the entire PID loop iteration to be skipped. In my test system this happens on almost every round, causing my 60-second PID controller to effectively only update every 120 seconds. Since we already only call simple_pid every sample_time seconds, there is no need to keep the simple_pid's filtering check in place. The filtering check is the only thing simple_pid uses the sample_time value for, so we can simply omit giving out our sample_time to it. --- custom_components/simple_pid_controller/sensor.py | 13 +++++++------ tests/test_sensor.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 5b8e414..1aebb90 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -34,8 +34,10 @@ async def async_setup_entry( handle: PIDDeviceHandle = entry.runtime_data.handle # Init PID with default values - pid = PID(1.0, 0.1, 0.05, setpoint=50) - pid.sample_time = 10.0 + # The sample_time parameter is just used to filter inputs, but we handle + # sending new inputs at the correct times ourselves so it would just + # filter out legitimate inputs. + pid = PID(1.0, 0.1, 0.05, setpoint=50, sample_time=None) pid.output_limits = (-10.0, 10.0) handle.last_contributions = (0, 0, 0, 0) @@ -60,7 +62,6 @@ async def update_pid(): # adapt PID settings pid.tunings = (kp, ki, kd) pid.setpoint = setpoint - pid.sample_time = sample_time if windup_protection: pid.output_limits = (out_min, out_max) else: @@ -95,11 +96,11 @@ async def update_pid(): handle.last_contributions[3], ) - if coordinator.update_interval.total_seconds() != pid.sample_time: + if coordinator.update_interval.total_seconds() != sample_time: _LOGGER.debug( - "Updating coordinator interval to %.2f seconds", pid.sample_time + "Updating coordinator interval to %.2f seconds", sample_time ) - coordinator.update_interval = timedelta(seconds=pid.sample_time) + coordinator.update_interval = timedelta(seconds=sample_time) return output diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 9201041..7688db3 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -152,7 +152,7 @@ async def test_update_pid_output_limits_none_when_windup_protection_disabled( # Dummy PID to inspect output_limits class DummyPID: - def __init__(self, kp, ki, kd, setpoint): + def __init__(self, kp, ki, kd, setpoint, sample_time=None): self._proportional = 1.0 self._integral = 1.0 self._last_output = None From 79c9518f67de20fc831f69967fdfeae55764506d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 20 Jun 2025 16:35:57 +0000 Subject: [PATCH 07/16] Added select tests, moved select to configuration --- .../simple_pid_controller/__init__.py | 13 ++-- .../simple_pid_controller/select.py | 2 + .../simple_pid_controller/sensor.py | 12 +--- tests/test_select.py | 65 +++++++++++++++++++ tests/test_sensor.py | 3 + 5 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 tests/test_select.py diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index e9b7d8f..9799d12 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -94,20 +94,17 @@ def get_number(self, key: str) -> float | None: ) return None - def get_select(self, key: str) -> float | None: - """Return the current value of the number entity, or None.""" + def get_select(self, key: str) -> str | None: + """Return the current value of the select entity, or None.""" entity_id = self._get_entity_id("select", key) if not entity_id: return None state = self.hass.states.get(entity_id) _LOGGER.debug("get_select(%s) → %s = %s", key, entity_id, state and state.state) + if state and state.state not in ("unknown", "unavailable"): - try: - return state.state - except ValueError: - _LOGGER.error( - "Could not parse state '%s' of %s", state.state, entity_id - ) + return state.state # Selects geven strings terug, geen conversie nodig + return None def get_switch(self, key: str) -> bool: diff --git a/custom_components/simple_pid_controller/select.py b/custom_components/simple_pid_controller/select.py index 534caf7..d396de2 100644 --- a/custom_components/simple_pid_controller/select.py +++ b/custom_components/simple_pid_controller/select.py @@ -1,5 +1,6 @@ from homeassistant.components.select import SelectEntity from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity import EntityCategory from .entity import BasePIDEntity @@ -25,6 +26,7 @@ def __init__(self, hass, entry, key, name, coordinator): super().__init__(hass, entry, key, name) self._attr_options = START_MODE_OPTIONS self._attr_current_option = START_MODE_OPTIONS[0] + self._attr_entity_category = EntityCategory.CONFIG self.coordinator = coordinator # if needed later async def async_select_option(self, option: str) -> None: diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index e8d2d20..907896c 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -72,27 +72,21 @@ async def update_pid(): else: pid.output_limits = (None, None) + _LOGGER.debug("Start mode = %s (type: %s)", start_mode, type(start_mode)) if (handle.init_phase and auto_mode) or (not pid.auto_mode and auto_mode): handle.init_phase = False if start_mode == "Zero start": pid.set_auto_mode(True, 0) elif start_mode == "Last known value": - pid.set_auto_mode(True, last_output=handle.last_known_output) + pid.set_auto_mode(True, handle.last_known_output) elif start_mode == "Startup value": - pid.set_auto_mode(True, last_output=starting_output) + pid.set_auto_mode(True, starting_output) else: pid.set_auto_mode(True) else: pid.auto_mode = auto_mode handle.init_phase = False - # if handle.init_phase: - # handle.init_phase = False - # #pid.set_auto_mode(True, last_output=handle.last_known_output) - # pid.set_auto_mode(True, last_output=starting_output) - # else: - # pid.auto_mode = auto_mode - pid.proportional_on_measurement = p_on_m output = pid(input_value) diff --git a/tests/test_select.py b/tests/test_select.py new file mode 100644 index 0000000..e1fd3a9 --- /dev/null +++ b/tests/test_select.py @@ -0,0 +1,65 @@ +import pytest +from datetime import timedelta +from homeassistant.util.dt import utcnow +from pytest_homeassistant_custom_component.common import async_fire_time_changed + + +@pytest.mark.parametrize( + "start_mode,expected_output", + [ + ("Zero start", 0.0), + ( + "Last known value", + 0.0, + ), # default `last_known_output` = None → fallback naar 0 + ("Startup value", 50.0), + ], +) +@pytest.mark.asyncio +async def test_pid_start_modes(hass, config_entry, start_mode, expected_output): + """Test that each PID start_mode behaves as expected.""" + sample_time = 5 + + handle = config_entry.runtime_data.handle + handle.init_phase = True # simulate init + + handle.get_input_sensor_value = lambda: 10.0 + handle.get_select = lambda key: start_mode if key == "start_mode" else None + handle.get_number = lambda key: { + "kp": 1.0, + "ki": 0.1, + "kd": 0.01, + "setpoint": 20.0, + "starting_output": 50.0, + "sample_time": sample_time, + "output_min": 0.0, + "output_max": 100.0, + }[key] + handle.get_switch = lambda key: True + + # 1) trigger initial update + hass.bus.async_fire("homeassistant_started") + await hass.async_block_till_done() + + # 2) simulate scheduled update + future = utcnow() + timedelta(seconds=sample_time) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # Check PID output sensor + out_entity = f"sensor.{config_entry.entry_id}_pid_output" + state = hass.states.get(out_entity) + + assert state is not None + output = float(state.state) + + # Output is not exactly predictable due to PID internals, but should not be None or error + assert isinstance(output, float) + + # Optional: check expected startup behavior — initial output close to what was forced + if start_mode == "Startup value": + assert output == pytest.approx(50.0, abs=1.0) + elif start_mode == "Zero start": + assert output == pytest.approx(0.0, abs=1.0) + elif start_mode == "Last known value": + assert output == pytest.approx(0.0, abs=1.0) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 06ee286..5e01845 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -15,6 +15,9 @@ async def test_pid_output_and_contributions_update(hass, config_entry): handle = config_entry.runtime_data.handle handle.get_input_sensor_value = lambda: 10.0 + handle.get_select = lambda key: { + "start_mode": "Startup value", + }[key] handle.get_number = lambda key: { "kp": 1.0, "ki": 0.1, From 914ae713d86fdf13f4ec559e9597a39b0878954e Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 20 Jun 2025 16:39:53 +0000 Subject: [PATCH 08/16] Fix linting errors --- custom_components/simple_pid_controller/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 1aebb90..630456e 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -97,9 +97,7 @@ async def update_pid(): ) if coordinator.update_interval.total_seconds() != sample_time: - _LOGGER.debug( - "Updating coordinator interval to %.2f seconds", sample_time - ) + _LOGGER.debug("Updating coordinator interval to %.2f seconds", sample_time) coordinator.update_interval = timedelta(seconds=sample_time) return output From 38cc74a835ac09b19359455f6c8abcb3b91680f5 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 20 Jun 2025 16:42:00 +0000 Subject: [PATCH 09/16] Cleanup comments --- custom_components/simple_pid_controller/sensor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 630456e..8cbcb4e 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -34,9 +34,6 @@ async def async_setup_entry( handle: PIDDeviceHandle = entry.runtime_data.handle # Init PID with default values - # The sample_time parameter is just used to filter inputs, but we handle - # sending new inputs at the correct times ourselves so it would just - # filter out legitimate inputs. pid = PID(1.0, 0.1, 0.05, setpoint=50, sample_time=None) pid.output_limits = (-10.0, 10.0) handle.last_contributions = (0, 0, 0, 0) From af4fefb26fac261f9cd568c171e31ac04ea23e44 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 21 Jun 2025 12:18:13 +0000 Subject: [PATCH 10/16] modified: tests/test_select.py --- tests/test_select.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_select.py b/tests/test_select.py index e1fd3a9..f4aa830 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -8,10 +8,7 @@ "start_mode,expected_output", [ ("Zero start", 0.0), - ( - "Last known value", - 0.0, - ), # default `last_known_output` = None → fallback naar 0 + ("Last known value", 80.0), ("Startup value", 50.0), ], ) @@ -22,14 +19,15 @@ async def test_pid_start_modes(hass, config_entry, start_mode, expected_output): handle = config_entry.runtime_data.handle handle.init_phase = True # simulate init + handle.last_known_output = 80 - handle.get_input_sensor_value = lambda: 10.0 + handle.get_input_sensor_value = lambda: 50.0 handle.get_select = lambda key: start_mode if key == "start_mode" else None handle.get_number = lambda key: { "kp": 1.0, "ki": 0.1, "kd": 0.01, - "setpoint": 20.0, + "setpoint": 50.0, "starting_output": 50.0, "sample_time": sample_time, "output_min": 0.0, @@ -62,4 +60,4 @@ async def test_pid_start_modes(hass, config_entry, start_mode, expected_output): elif start_mode == "Zero start": assert output == pytest.approx(0.0, abs=1.0) elif start_mode == "Last known value": - assert output == pytest.approx(0.0, abs=1.0) + assert output == pytest.approx(80.0, abs=1.0) From 1b5d42ab394c4abe5062a6d9eef2a238c4aeef6d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 21 Jun 2025 14:18:54 +0200 Subject: [PATCH 11/16] Update README.md --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index 8e7d58e..6a3b142 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ - [PID Tuning Guide](#pid-tuning-guide) - [Manual Tuning](#1-manual-trial--error) - [Ziegler–Nichols Method](#2-zieglernichols-method) -- [PID Calculation Frequency and Sample Time](#pid-calculation-frequency-and-sample-time) - [More details and extended documentation](#extended-documentation) - [Example PID Graph](#example-pid-graph) - [Support & Development](#support--development) @@ -144,13 +143,6 @@ A PID controller continuously corrects the difference between a **setpoint** and --- -## PID Calculation Frequency and Sample Time - -This integration recalculates the PID output at a fixed, user-configurable interval to ensure timely and consistent control updates. Internally, both the PID loop and Home Assistant’s data coordinator use the same **Sample Time**, but they each maintain their own timer, so slight differences can occur. - -- **Sample Time** - The minimum number of seconds between successive PID computations. For example, if you set `sample_time: 5`, both the PID controller and the update coordinator are scheduled to fire every 5 seconds. - ### How it works in practice 1. **Initialization** From bddc66874fab647ebb04c25dbd231d323bf617fa Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 21 Jun 2025 14:27:05 +0200 Subject: [PATCH 12/16] Fix PID in handle --- .../simple_pid_controller/sensor.py | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 1e62953..a1f10d7 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -36,9 +36,9 @@ async def async_setup_entry( handle.init_phase = True # Init PID with default values - pid = PID(1.0, 0.1, 0.05, setpoint=50, sample_time=None) + handle.pid = PID(1.0, 0.1, 0.05, setpoint=50, sample_time=None) - pid.output_limits = (-10.0, 10.0) + handle.pid.output_limits = (-10.0, 10.0) handle.last_contributions = (0, 0, 0, 0) handle.last_known_output = None @@ -63,32 +63,32 @@ async def update_pid(): windup_protection = handle.get_switch("windup_protection") # adapt PID settings - pid.tunings = (kp, ki, kd) - pid.setpoint = setpoint + handle.pid.tunings = (kp, ki, kd) + handle.pid.setpoint = setpoint if windup_protection: - pid.output_limits = (out_min, out_max) + handle.pid.output_limits = (out_min, out_max) else: - pid.output_limits = (None, None) + handle.pid.output_limits = (None, None) _LOGGER.debug("Start mode = %s (type: %s)", start_mode, type(start_mode)) - if (handle.init_phase and auto_mode) or (not pid.auto_mode and auto_mode): + if (handle.init_phase and auto_mode) or (not handle.pid.auto_mode and auto_mode): handle.init_phase = False if start_mode == "Zero start": - pid.set_auto_mode(True, 0) + handle.pid.set_auto_mode(True, 0) elif start_mode == "Last known value": - pid.set_auto_mode(True, handle.last_known_output) + handle.pid.set_auto_mode(True, handle.last_known_output) elif start_mode == "Startup value": - pid.set_auto_mode(True, starting_output) + handle.pid.set_auto_mode(True, starting_output) else: - pid.set_auto_mode(True) + handle.pid.set_auto_mode(True) else: - pid.auto_mode = auto_mode + handle.pid.auto_mode = auto_mode handle.init_phase = False - pid.proportional_on_measurement = p_on_m + handle.pid.proportional_on_measurement = p_on_m - output = pid(input_value) + output = handle.pid(input_value) # save last know output handle.last_known_output = output @@ -98,19 +98,19 @@ async def update_pid(): # save all latest contributions handle.last_contributions = ( - pid.components[0], - pid.components[1], - pid.components[2], - pid.components[1] - last_i, + handle.pid.components[0], + handle.pid.components[1], + handle.pid.components[2], + handle.pid.components[1] - last_i, ) _LOGGER.debug( "PID input=%s setpoint=%s kp=%s ki=%s kd=%s => output=%s [P=%s, I=%s, D=%s, dI=%s]", input_value, - pid.setpoint, - pid.Kp, - pid.Ki, - pid.Kd, + handle.pid.setpoint, + handle.pid.Kp, + handle.pid.Ki, + handle.pid.Kd, output, handle.last_contributions[0], handle.last_contributions[1], From 19007f4aa472eca46d93a51cf6afa516db2be93b Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 21 Jun 2025 16:45:06 +0000 Subject: [PATCH 13/16] Update tests --- tests/test_select.py | 104 ++++++++++++++++++++----------------------- tests/test_sensor.py | 74 +++++++++++++++++++++--------- 2 files changed, 101 insertions(+), 77 deletions(-) diff --git a/tests/test_select.py b/tests/test_select.py index f4aa830..31fb7e3 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -4,60 +4,54 @@ from pytest_homeassistant_custom_component.common import async_fire_time_changed -@pytest.mark.parametrize( - "start_mode,expected_output", - [ - ("Zero start", 0.0), - ("Last known value", 80.0), - ("Startup value", 50.0), - ], -) @pytest.mark.asyncio -async def test_pid_start_modes(hass, config_entry, start_mode, expected_output): - """Test that each PID start_mode behaves as expected.""" - sample_time = 5 +async def test_pid_start_modes(hass, config_entry): + """Vergelijk het effect van verschillende startmodi op de PID-uitgang.""" - handle = config_entry.runtime_data.handle - handle.init_phase = True # simulate init - handle.last_known_output = 80 - - handle.get_input_sensor_value = lambda: 50.0 - handle.get_select = lambda key: start_mode if key == "start_mode" else None - handle.get_number = lambda key: { - "kp": 1.0, - "ki": 0.1, - "kd": 0.01, - "setpoint": 50.0, - "starting_output": 50.0, - "sample_time": sample_time, - "output_min": 0.0, - "output_max": 100.0, - }[key] - handle.get_switch = lambda key: True - - # 1) trigger initial update - hass.bus.async_fire("homeassistant_started") - await hass.async_block_till_done() - - # 2) simulate scheduled update - future = utcnow() + timedelta(seconds=sample_time) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - # Check PID output sensor - out_entity = f"sensor.{config_entry.entry_id}_pid_output" - state = hass.states.get(out_entity) - - assert state is not None - output = float(state.state) - - # Output is not exactly predictable due to PID internals, but should not be None or error - assert isinstance(output, float) - - # Optional: check expected startup behavior — initial output close to what was forced - if start_mode == "Startup value": - assert output == pytest.approx(50.0, abs=1.0) - elif start_mode == "Zero start": - assert output == pytest.approx(0.0, abs=1.0) - elif start_mode == "Last known value": - assert output == pytest.approx(80.0, abs=1.0) + sample_time = 5 + base_input = 40.0 + setpoint = 50.0 + + results = {} + + for start_mode in ["Zero start", "Startup value", "Last known value"]: + # reset de PID state per iteratie + handle = config_entry.runtime_data.handle + handle.init_phase = True + handle.last_known_output = 80.0 + + handle.get_input_sensor_value = lambda: base_input + handle.get_select = lambda key: start_mode if key == "start_mode" else None + handle.get_number = lambda key: { + "kp": 1.0, + "ki": 0.1, + "kd": 0.01, + "setpoint": setpoint, + "starting_output": 50.0, + "sample_time": sample_time, + "output_min": 0.0, + "output_max": 100.0, + }[key] + handle.get_switch = lambda key: True + + # trigger initial update + hass.bus.async_fire("homeassistant_started") + await hass.async_block_till_done() + + # simulate one PID update + future = utcnow() + timedelta(seconds=sample_time) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + out_entity = f"sensor.{config_entry.entry_id}_pid_output" + state = hass.states.get(out_entity) + assert state is not None + + output = float(state.state) + results[start_mode] = output + print(f"{start_mode} → output: {output:.2f}") + + # Check relatieve rangorde + assert ( + results["Last known value"] > results["Startup value"] > results["Zero start"] + ) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index afd06e9..3bd5ed4 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -151,39 +151,69 @@ async def test_update_pid_raises_on_missing_input(hass, config_entry): async def test_update_pid_output_limits_none_when_windup_protection_disabled( monkeypatch, hass, config_entry ): - """Line 68: update_pid should set output_limits to (None, None) when windup_protection is False.""" - from custom_components.simple_pid_controller import sensor as sensor_module + """Test output_limits (None, None)""" - # Dummy PID to inspect output_limits + # Dummy PID class class DummyPID: - def __init__(self, kp, ki, kd, setpoint, sample_time=None): - self._proportional = 1.0 - self._integral = 1.0 - self._last_output = None - self.tunings = (kp, ki, kd) + def __init__(self, kp=0, ki=0, kd=0, setpoint=0, sample_time=None): + self.Kp = kp + self.Ki = ki + self.Kd = kd self.setpoint = setpoint - self.sample_time = 1.0 - self.output_limits = (0.0, 0.0) - self.auto_mode = auto_mode + self.sample_time = sample_time + self.auto_mode = False self.proportional_on_measurement = False + self.tunings = (kp, ki, kd) + self.output_limits = (123, 456) # dummy init waarde + self._output = 42.0 + self.components = (1.0, 2.0, 3.0) # dummy voor sensor.py + + def set_auto_mode(self, enabled, last_output=None): + self.auto_mode = enabled + if last_output is not None: + self._output = last_output def __call__(self, input_value): - return 0.0 + return self._output + + # Patch the PID in sensor-module + from custom_components.simple_pid_controller import sensor as sensor_module monkeypatch.setattr(sensor_module, "PID", DummyPID) + + # init handle handle = config_entry.runtime_data.handle - # Provide valid input and parameters - handle.get_input_sensor_value = lambda: 1.0 - handle.get_number = lambda key: 0.0 - # windup_protection False, other switches True + handle.last_contributions = (0.0, 0.0, 0.0, 0.0) + handle.last_known_output = 0.0 + handle.get_input_sensor_value = lambda: 10.0 + handle.get_number = lambda key: { + "kp": 1.0, + "ki": 0.1, + "kd": 0.01, + "setpoint": 5.0, + "starting_output": 0.0, + "sample_time": 5.0, + "output_min": 0.0, + "output_max": 100.0, + }.get(key, 0.0) handle.get_switch = lambda key: False if key == "windup_protection" else True - entities: list = [] + handle.get_select = lambda key: "Zero start" if key == "start_mode" else None + + # Set-up components and trigger update + entities = [] await async_setup_entry(hass, config_entry, lambda e: entities.extend(e)) coordinator = entities[0].coordinator - # Execute update_method await coordinator.update_method() - # Extract pid from closure - freevars = coordinator.update_method.__code__.co_freevars - pid_idx = freevars.index("pid") - pid = coordinator.update_method.__closure__[pid_idx].cell_contents + + # Extract thee DummyPID from closure of update_method + closure_vars = { + var: cell.cell_contents + for var, cell in zip( + coordinator.update_method.__code__.co_freevars, + coordinator.update_method.__closure__, + ) + } + pid = closure_vars["handle"].pid + + # check output_limits assert pid.output_limits == (None, None) From 1ae8308a577e839b1e5b736dc85299b56b3bd0a3 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 21 Jun 2025 17:07:35 +0000 Subject: [PATCH 14/16] Add tests --- .../simple_pid_controller/number.py | 2 +- .../simple_pid_controller/sensor.py | 9 ++++- tests/test_device_handle.py | 35 +++++++++++++++++++ tests/test_select.py | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index bfbad6d..7d4b337 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -97,7 +97,7 @@ "entity_category": EntityCategory.CONFIG, }, { - "name": "Starting Output", + "name": "Startup Value", "key": "starting_output", "unit": "", "step": 1.0, diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index a1f10d7..4af2a8f 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -72,7 +72,9 @@ async def update_pid(): handle.pid.output_limits = (None, None) _LOGGER.debug("Start mode = %s (type: %s)", start_mode, type(start_mode)) - if (handle.init_phase and auto_mode) or (not handle.pid.auto_mode and auto_mode): + if (handle.init_phase and auto_mode) or ( + not handle.pid.auto_mode and auto_mode + ): handle.init_phase = False if start_mode == "Zero start": handle.pid.set_auto_mode(True, 0) @@ -184,6 +186,11 @@ def _listener(event): "state_changed", make_listener(f"switch.{entry.entry_id}_{key}") ) + for key in ["start_mode"]: + hass.bus.async_listen( + "state_changed", make_listener(f"select.{entry.entry_id}_{key}") + ) + class PIDOutputSensor( CoordinatorEntity[PIDDataCoordinator], RestoreEntity, SensorEntity diff --git a/tests/test_device_handle.py b/tests/test_device_handle.py index 2f367bf..30fcf98 100644 --- a/tests/test_device_handle.py +++ b/tests/test_device_handle.py @@ -100,3 +100,38 @@ async def test_get_switch_returns_true_when_no_entity_configured(hass, config_en # Force no entity_id handle._get_entity_id = lambda platform, key: None assert handle.get_switch("any_key") is True + + +@pytest.mark.parametrize( + "state_value, expected", + [ + ("Zero start", "Zero start"), # valid select option + ("unknown", None), # invalid + ("unavailable", None), # invalid + (None, None), # no state + ], +) +def test_get_select_various_states( + monkeypatch, hass, config_entry, state_value, expected +): + """Test PIDDeviceHandle.get_select behavior for valid and invalid entity states.""" + + fake_eid = "select.pid_entry_start_mode" + + # Inject fake state + if state_value is not None: + hass.states.async_set(fake_eid, state_value) + + # Patch entity_registry.async_get(hass) → returns dummy registry object + class DummyRegistry: + def async_get_entity_id(self, platform, domain, unique_id): + if domain == DOMAIN and unique_id.endswith("start_mode"): + return fake_eid + return None + + monkeypatch.setattr(er, "async_get", lambda hass: DummyRegistry()) + + handle = PIDDeviceHandle(hass, config_entry) + result = handle.get_select("start_mode") + + assert result == expected diff --git a/tests/test_select.py b/tests/test_select.py index 31fb7e3..c23742c 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -6,7 +6,7 @@ @pytest.mark.asyncio async def test_pid_start_modes(hass, config_entry): - """Vergelijk het effect van verschillende startmodi op de PID-uitgang.""" + """Check start modes.""" sample_time = 5 base_input = 40.0 From 4fe8bd16d02043ba9c0da5c6ce5aee187c8c16ed Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 23 Jun 2025 07:30:05 +0000 Subject: [PATCH 15/16] Updated tests --- tests/test_device_handle.py | 10 +++ tests/test_select.py | 75 +++++++++++++++++++ tests/test_sensor.py | 142 +++++++++++++++++++++++++++++++++++- 3 files changed, 226 insertions(+), 1 deletion(-) diff --git a/tests/test_device_handle.py b/tests/test_device_handle.py index 30fcf98..1976c68 100644 --- a/tests/test_device_handle.py +++ b/tests/test_device_handle.py @@ -135,3 +135,13 @@ def async_get_entity_id(self, platform, domain, unique_id): result = handle.get_select("start_mode") assert result == expected + + +def test_get_select_no_entity(monkeypatch, hass, config_entry): + """If _get_entity_id returns None, get_select should return None.""" + # Patch de registry zo dat er geen entity_id wordt gevonden + monkeypatch.setattr(er, "async_get", lambda hass_: DummyRegistry(None)) + + handle = PIDDeviceHandle(hass, config_entry) + # Key mag willekeurig zijn, er is immers geen entity + assert handle.get_select("nonexistent_key") is None diff --git a/tests/test_select.py b/tests/test_select.py index c23742c..5425762 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -2,6 +2,10 @@ from datetime import timedelta from homeassistant.util.dt import utcnow from pytest_homeassistant_custom_component.common import async_fire_time_changed +from custom_components.simple_pid_controller.select import ( + START_MODE_OPTIONS, + PIDStartModeSelect, +) @pytest.mark.asyncio @@ -55,3 +59,74 @@ async def test_pid_start_modes(hass, config_entry): assert ( results["Last known value"] > results["Startup value"] > results["Zero start"] ) + + +@pytest.mark.asyncio +async def test_async_select_option_applies_only_valid_options( + hass, config_entry, monkeypatch +): + """Test that async_select_option applies valid options and ignores invalid ones.""" + # Arrange: setup coordinator and select entity as in async_setup_entry + coordinator = config_entry.runtime_data.coordinator + select = PIDStartModeSelect( + hass, config_entry, "start_mode", "PID Start Mode", coordinator + ) + + # Default current option should be the first entry + assert select._attr_current_option == START_MODE_OPTIONS[0] + + # Stub out HA state write calls to track invocations + write_calls = [] + monkeypatch.setattr( + select, "async_write_ha_state", lambda: write_calls.append(True) + ) + + # Act & Assert 1: choosing a valid option updates current_option and writes state + valid_option = START_MODE_OPTIONS[1] + await select.async_select_option(valid_option) + assert select._attr_current_option == valid_option + assert write_calls, "async_write_ha_state should be called for a valid option" + + # Act & Assert 2: choosing an invalid option leaves current_option unchanged and does not write + write_calls.clear() + await select.async_select_option("not_an_option") + assert select._attr_current_option == valid_option + assert ( + not write_calls + ), "async_write_ha_state should not be called for an invalid option" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "last_state, expected_option", + [(opt, opt) for opt in START_MODE_OPTIONS], +) +async def test_async_added_to_hass_restores_previous_state( + hass, config_entry, monkeypatch, last_state, expected_option +): + """Test that async_added_to_hass restores last_state when it's a valid option.""" + # Arrange + coordinator = config_entry.runtime_data.coordinator + select = PIDStartModeSelect( + hass, config_entry, "start_mode", "PID Start Mode", coordinator + ) + + class LastState: + state = last_state + + async def fake_get_last_state(): + return LastState + + monkeypatch.setattr(select, "async_get_last_state", fake_get_last_state) + + # Set the current option to something else to ensure restoration occurs + for opt in START_MODE_OPTIONS: + if opt != expected_option: + select._attr_current_option = opt + break + + # Act + await select.async_added_to_hass() + + # Assert + assert select._attr_current_option == expected_option diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 3bd5ed4..cfe7729 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -2,9 +2,13 @@ from datetime import timedelta from homeassistant.util.dt import utcnow from pytest_homeassistant_custom_component.common import async_fire_time_changed -from custom_components.simple_pid_controller.sensor import PIDContributionSensor +from custom_components.simple_pid_controller.sensor import ( + PIDContributionSensor, + PIDOutputSensor, +) from custom_components.simple_pid_controller.coordinator import PIDDataCoordinator from custom_components.simple_pid_controller.sensor import async_setup_entry +from custom_components.simple_pid_controller import sensor as sensor_module @pytest.mark.asyncio @@ -217,3 +221,139 @@ def __call__(self, input_value): # check output_limits assert pid.output_limits == (None, None) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "restored_state, expected_last_known", + [ + ("12.34", 12.34), + ("not_a_number", 0.0), + (None, None), + ], +) +async def test_async_added_to_hass_restores_last_known_output( + hass, config_entry, monkeypatch, restored_state, expected_last_known +): + """Test that async_added_to_hass sets handle.last_known_output from a saved state or falls back to 0.0.""" + handle = config_entry.runtime_data.handle + + # Disable the coordinator's periodic scheduling to avoid lingering timers in the test + monkeypatch.setattr(PIDDataCoordinator, "_schedule_refresh", lambda self, *_: None) + + # Maak een coordinator aan (update interval is verder niet relevant voor deze test) + coordinator = PIDDataCoordinator( + hass, config_entry.entry_id, lambda: None, interval=1 + ) + sensor = PIDOutputSensor(hass, config_entry, coordinator) + sensor.handle = handle + + # Simuleer het terughalen van de laatst bekende staat + async def fake_get_last_state(): + if restored_state is None: + return None + return type("State", (), {"state": restored_state}) + + monkeypatch.setattr(sensor, "async_get_last_state", fake_get_last_state) + + # Act: roep de restore-hook aan + await sensor.async_added_to_hass() + + # Assert: handle.last_known_output is juist ingesteld + assert handle.last_known_output == expected_last_known + + +@pytest.mark.asyncio +async def test_update_pid_invalid_start_mode_defaults(monkeypatch, hass, config_entry): + """Line 86: invalid start_mode calls set_auto_mode(True) without changing output.""" + + # Dummy PID class matching existing tests + class DummyPID: + def __init__(self, kp=0, ki=0, kd=0, setpoint=0, sample_time=None): + self.Kp = kp + self.Ki = ki + self.Kd = kd + self.setpoint = setpoint + self.sample_time = sample_time + self.auto_mode = False + self.proportional_on_measurement = False + self.tunings = (kp, ki, kd) + self.output_limits = (123, 456) + self._output = 42.0 + self.components = (1.0, 2.0, 3.0) + + def set_auto_mode(self, enabled, last_output=None): + self.auto_mode = enabled + if last_output is not None: + self._output = last_output + + def __call__(self, input_value): + return self._output + + # Patch the PID class in the sensor module + monkeypatch.setattr(sensor_module, "PID", DummyPID) + + # Prepare the handle + handle = config_entry.runtime_data.handle + handle.last_contributions = (0.0, 0.0, 0.0, 0.0) + handle.last_known_output = 99.9 # some non‐zero initial + handle.get_input_sensor_value = lambda: 10.0 + handle.get_number = lambda key: { + "kp": 1.0, + "ki": 0.1, + "kd": 0.01, + "setpoint": 5.0, + "starting_output": 0.0, + "sample_time": 5.0, + "output_min": 0.0, + "output_max": 100.0, + }[key] + handle.get_switch = lambda key: True + handle.get_select = lambda key: "Invalid Mode" if key == "start_mode" else None + + # Run setup and trigger one PID update + entities = [] + await sensor_module.async_setup_entry( + hass, config_entry, lambda e: entities.extend(e) + ) + coordinator = entities[0].coordinator + await coordinator.update_method() + + # Extract the PID instance from the closure + closure_vars = { + var: cell.cell_contents + for var, cell in zip( + coordinator.update_method.__code__.co_freevars, + coordinator.update_method.__closure__, + ) + } + pid = closure_vars["handle"].pid + + # Assert that auto_mode turned True and output stayed at the DummyPID default + assert pid.auto_mode is True + assert pid._output == 42.0 + + +def test_pid_contribution_error_when_input_or_setpoint_none(hass, config_entry): + """Line 258: native_value for 'error' should be 0 when input or setpoint is None.""" + handle = config_entry.runtime_data.handle + handle.last_contributions = (1.0, 2.0, 3.0, 4.0) + coordinator = PIDDataCoordinator(hass, "test", lambda: 0, interval=1) + + # Case 1: input_value is None → error = 0 + handle.get_input_sensor_value = lambda: None + handle.get_number = lambda key: 5.0 + sensor = PIDContributionSensor( + hass, config_entry, "error", "Error Sensor", coordinator + ) + sensor._handle = handle + assert sensor.native_value == 0 + + # Case 2: setpoint is None → error = 0 + handle.get_input_sensor_value = lambda: 10.0 + handle.get_number = lambda key: None + sensor = PIDContributionSensor( + hass, config_entry, "error", "Error Sensor", coordinator + ) + sensor._handle = handle + assert sensor.native_value == 0 From beb35be49604a9e1ab7fc2c3b4862b327fd6e443 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 23 Jun 2025 09:41:43 +0200 Subject: [PATCH 16/16] Update version --- .bumpversion.toml | 2 +- custom_components/simple_pid_controller/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 5a5a311..b466598 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "1.3.0" +current_version = "1.4.0" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" serialize = ["{major}.{minor}.{patch}"] search = "{current_version}" diff --git a/custom_components/simple_pid_controller/manifest.json b/custom_components/simple_pid_controller/manifest.json index 62f396f..dfe1cca 100644 --- a/custom_components/simple_pid_controller/manifest.json +++ b/custom_components/simple_pid_controller/manifest.json @@ -11,6 +11,6 @@ "quality_scale": "silver", "requirements": ["simple-pid==2.0.1"], "ssdp": [], - "version": "1.3.0", + "version": "1.4.0", "zeroconf": [] }