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/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** diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 6d8826c..9799d12 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,19 @@ def get_number(self, key: str) -> float | None: ) return 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"): + return state.state # Selects geven strings terug, geen conversie nodig + + 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/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": [] } diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 7b02619..7d4b337 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, }, @@ -96,6 +96,14 @@ "default": 1, "entity_category": EntityCategory.CONFIG, }, + { + "name": "Startup Value", + "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 + 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 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/select.py b/custom_components/simple_pid_controller/select.py new file mode 100644 index 0000000..d396de2 --- /dev/null +++ b/custom_components/simple_pid_controller/select.py @@ -0,0 +1,44 @@ +from homeassistant.components.select import SelectEntity +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity import EntityCategory + +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._attr_entity_category = EntityCategory.CONFIG + 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 5b8e414..4af2a8f 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 @@ -32,12 +33,14 @@ 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 - pid = PID(1.0, 0.1, 0.05, setpoint=50) - pid.sample_time = 10.0 - pid.output_limits = (-10.0, 10.0) + handle.pid = PID(1.0, 0.1, 0.05, setpoint=50, sample_time=None) + + handle.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.""" @@ -50,6 +53,8 @@ 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") + 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") @@ -58,36 +63,56 @@ async def update_pid(): windup_protection = handle.get_switch("windup_protection") # adapt PID settings - pid.tunings = (kp, ki, kd) - pid.setpoint = setpoint - pid.sample_time = sample_time + 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) - pid.auto_mode = auto_mode - pid.proportional_on_measurement = p_on_m + 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 + ): + handle.init_phase = False + if start_mode == "Zero start": + handle.pid.set_auto_mode(True, 0) + elif start_mode == "Last known value": + handle.pid.set_auto_mode(True, handle.last_known_output) + elif start_mode == "Startup value": + handle.pid.set_auto_mode(True, starting_output) + else: + handle.pid.set_auto_mode(True) + else: + handle.pid.auto_mode = auto_mode + handle.init_phase = False + + 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 # save last I contribution last_i = handle.last_contributions[1] # 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], @@ -95,11 +120,9 @@ async def update_pid(): handle.last_contributions[3], ) - if coordinator.update_interval.total_seconds() != pid.sample_time: - _LOGGER.debug( - "Updating coordinator interval to %.2f seconds", pid.sample_time - ) - coordinator.update_interval = timedelta(seconds=pid.sample_time) + if coordinator.update_interval.total_seconds() != sample_time: + _LOGGER.debug("Updating coordinator interval to %.2f seconds", sample_time) + coordinator.update_interval = timedelta(seconds=sample_time) return output @@ -163,8 +186,15 @@ 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], SensorEntity): +class PIDOutputSensor( + CoordinatorEntity[PIDDataCoordinator], RestoreEntity, SensorEntity +): """Sensor representing the PID output.""" def __init__( @@ -179,6 +209,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: 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 diff --git a/tests/test_device_handle.py b/tests/test_device_handle.py index 2f367bf..1976c68 100644 --- a/tests/test_device_handle.py +++ b/tests/test_device_handle.py @@ -100,3 +100,48 @@ 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 + + +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 new file mode 100644 index 0000000..5425762 --- /dev/null +++ b/tests/test_select.py @@ -0,0 +1,132 @@ +import pytest +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 +async def test_pid_start_modes(hass, config_entry): + """Check start modes.""" + + 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"] + ) + + +@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 9201041..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 @@ -15,11 +19,15 @@ 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, "kd": 0.01, "setpoint": 20.0, + "starting_output": 50.0, "sample_time": sample_time, "output_min": 0.0, "output_max": 100.0, @@ -147,39 +155,205 @@ 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): - 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 = True + 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) + + +@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