From d48aa4143fc9a19646db22cfd8992589a501ed34 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 19 Jul 2025 00:43:40 +0200 Subject: [PATCH 01/30] Reuse shared handle in sensors --- .../simple_pid_controller/entity.py | 67 +++++++++---------- .../simple_pid_controller/sensor.py | 6 +- tests/test_sensor.py | 8 +-- 3 files changed, 39 insertions(+), 42 deletions(-) diff --git a/custom_components/simple_pid_controller/entity.py b/custom_components/simple_pid_controller/entity.py index 8b5835b..717be03 100644 --- a/custom_components/simple_pid_controller/entity.py +++ b/custom_components/simple_pid_controller/entity.py @@ -1,34 +1,33 @@ -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity, DeviceInfo - -from .const import DOMAIN -from . import PIDDeviceHandle - - -class BasePIDEntity(Entity): - """Base entity for Simple PID Controller integration.""" - - def __init__( - self, - hass: HomeAssistant, - entry: ConfigEntry, - key: str, - name: str, - ) -> None: - """Initialize the base PID entity.""" - self.hass = hass - self._entry = entry - self._handle = PIDDeviceHandle(hass, entry) - self._key = key - - # Common entity attributes - self._attr_name = f"{name}" - self._attr_has_entity_name = True - self._attr_unique_id = f"{entry.entry_id}_{key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=self._handle.name, - ) +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity, DeviceInfo + +from .const import DOMAIN + + +class BasePIDEntity(Entity): + """Base entity for Simple PID Controller integration.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + key: str, + name: str, + ) -> None: + """Initialize the base PID entity.""" + self.hass = hass + self._entry = entry + self._handle = entry.runtime_data.handle + self._key = key + + # Common entity attributes + self._attr_name = f"{name}" + self._attr_has_entity_name = True + self._attr_unique_id = f"{entry.entry_id}_{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=self._handle.name, + ) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 5b4d5c6..8bea2b9 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -204,16 +204,15 @@ 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 + 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: @@ -241,7 +240,6 @@ def __init__( self._attr_entity_registry_enabled_default = False self._attr_state_class = SensorStateClass.MEASUREMENT self._key = key - self._handle = entry.runtime_data.handle @property def native_value(self): diff --git a/tests/test_sensor.py b/tests/test_sensor.py index dfd2b11..fba7593 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -76,7 +76,7 @@ async def test_pid_contribution_native_value_rounding_and_none(hass, config_entr f"sensor.{config_entry.entry_id}_{key}", coordinator, ) - sensor._handle = handle # inject mock handle + assert sensor._handle is handle assert sensor.native_value == expected # Unknown key should return None @@ -87,7 +87,7 @@ async def test_pid_contribution_native_value_rounding_and_none(hass, config_entr "sensor.{config_entry.entry_id}_pid_x_contrib", coordinator, ) - sensor_none._handle = handle + assert sensor_none._handle is handle assert sensor_none.native_value is None @@ -350,7 +350,7 @@ def test_pid_contribution_error_when_input_or_setpoint_none(hass, config_entry): sensor = PIDContributionSensor( hass, config_entry, "error", "Error Sensor", coordinator ) - sensor._handle = handle + assert sensor._handle is handle assert sensor.native_value == 0 # Case 2: setpoint is None → error = 0 @@ -359,5 +359,5 @@ def test_pid_contribution_error_when_input_or_setpoint_none(hass, config_entry): sensor = PIDContributionSensor( hass, config_entry, "error", "Error Sensor", coordinator ) - sensor._handle = handle + assert sensor._handle is handle assert sensor.native_value == 0 From fec9190820af9acfc379a4f53d30902cfd7f6bc3 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 19 Jul 2025 00:59:11 +0200 Subject: [PATCH 02/30] Remove stubs and fix unload listeners test --- .../simple_pid_controller/sensor.py | 9 +++-- tests/test_unload_listeners.py | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 tests/test_unload_listeners.py diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 5b4d5c6..c392e6f 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -172,19 +172,22 @@ def _listener(event): "output_max", "sample_time", ]: - hass.bus.async_listen( + unsub = hass.bus.async_listen( "state_changed", make_listener(f"number.{entry.entry_id}_{key}") ) + entry.async_on_unload(unsub) for key in ["auto_mode", "proportional_on_measurement", "windup_protection"]: - hass.bus.async_listen( + unsub = hass.bus.async_listen( "state_changed", make_listener(f"switch.{entry.entry_id}_{key}") ) + entry.async_on_unload(unsub) for key in ["start_mode"]: - hass.bus.async_listen( + unsub = hass.bus.async_listen( "state_changed", make_listener(f"select.{entry.entry_id}_{key}") ) + entry.async_on_unload(unsub) class PIDOutputSensor( diff --git a/tests/test_unload_listeners.py b/tests/test_unload_listeners.py new file mode 100644 index 0000000..9d29e5b --- /dev/null +++ b/tests/test_unload_listeners.py @@ -0,0 +1,33 @@ +import pytest + +from custom_components.simple_pid_controller.sensor import async_setup_entry + + +@pytest.fixture(autouse=True) +async def skip_setup_integration(): + """Override autouse setup from conftest; tests will setup manually.""" + yield + + +@pytest.mark.asyncio +async def test_listeners_removed_after_unload(hass, config_entry, monkeypatch): + """Ensure state change listeners are unsubscribed when the entry unloads.""" + created = [] + called = [] + + def fake_listen(self, event, callback): + def unsub(): + called.append(True) + created.append(unsub) + return unsub + + monkeypatch.setattr(type(hass.bus), "async_listen", fake_listen) + + await async_setup_entry(hass, config_entry, lambda e: None) + + # Expect listeners created for all parameters + assert len(created) > 0 + + await hass.config_entries.async_unload(config_entry.entry_id) + + assert len(called) == len(created) From 8d000e725f2be7a51c07638071de3e5971265524 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 20 Jul 2025 16:01:12 +0200 Subject: [PATCH 03/30] Improve issue labeling workflow --- .github/labeler.yml | 6 ++++++ .github/workflows/label-issues.yml | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 5cbb2ec..ba646e2 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -4,3 +4,9 @@ bug: feature: - "feature request" - "enhancement" +documentation: + - "docs" + - "documentation" +maintenance: + - "dependabot" + diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml index 9a3db9f..4d9693c 100644 --- a/.github/workflows/label-issues.yml +++ b/.github/workflows/label-issues.yml @@ -11,6 +11,8 @@ jobs: permissions: issues: write steps: - - uses: peaceiris/actions-issue-labeler@v1 + - uses: github/issue-labeler@v3 with: - github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + From aa3c0fdfc94b2e53eeddb9e5bf63465cee1bce07 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 20 Jul 2025 19:23:26 +0200 Subject: [PATCH 04/30] Add test for invalid last state handling in select --- tests/test_select.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_select.py b/tests/test_select.py index 5425762..6cbfe26 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -130,3 +130,27 @@ async def fake_get_last_state(): # Assert assert select._attr_current_option == expected_option + + +@pytest.mark.asyncio +async def test_async_added_to_hass_invalid_last_state(hass, config_entry, monkeypatch): + """Ensure invalid last state does not override default option.""" + coordinator = config_entry.runtime_data.coordinator + select = PIDStartModeSelect( + hass, config_entry, "start_mode", "PID Start Mode", coordinator + ) + + class LastState: + state = "invalid_option" + + async def fake_get_last_state(): + return LastState + + monkeypatch.setattr(select, "async_get_last_state", fake_get_last_state) + + default_option = START_MODE_OPTIONS[0] + assert select._attr_current_option == default_option + + await select.async_added_to_hass() + + assert select._attr_current_option == default_option From abf492d8c57fead5037e782f8a22e23562173a63 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 20 Jul 2025 19:23:35 +0200 Subject: [PATCH 05/30] Add update_interval change test --- tests/test_sensor.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index fba7593..bc1cbb7 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -361,3 +361,42 @@ def test_pid_contribution_error_when_input_or_setpoint_none(hass, config_entry): ) assert sensor._handle is handle assert sensor.native_value == 0 + + +@pytest.mark.asyncio +async def test_update_pid_adjusts_update_interval(hass, config_entry, monkeypatch): + """Ensure coordinator.update_interval updates when sample_time changes.""" + + monkeypatch.setattr(PIDDataCoordinator, "_schedule_refresh", lambda self, *_: None) + + handle = config_entry.runtime_data.handle + + sample_time = 5 + + 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": 0.0, + "sample_time": sample_time, + "output_min": 0.0, + "output_max": 100.0, + }[key] + handle.get_switch = lambda key: True + + entities = [] + await async_setup_entry(hass, config_entry, lambda e: entities.extend(e)) + coordinator = entities[0].coordinator + + assert coordinator.update_interval.total_seconds() == 10 + + await coordinator.update_method() + assert coordinator.update_interval == timedelta(seconds=sample_time) + + sample_time = 15 + await coordinator.update_method() + assert coordinator.update_interval == timedelta(seconds=sample_time) + From ebbb6d0821f6be20b82cda3102cb9866ffd44f0f Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 20 Jul 2025 19:23:43 +0200 Subject: [PATCH 06/30] Add test for options update listener --- tests/test_update_options_listener.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/test_update_options_listener.py diff --git a/tests/test_update_options_listener.py b/tests/test_update_options_listener.py new file mode 100644 index 0000000..053d17b --- /dev/null +++ b/tests/test_update_options_listener.py @@ -0,0 +1,27 @@ +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.simple_pid_controller.const import DOMAIN +from custom_components.simple_pid_controller import _async_update_options_listener + + +@pytest.fixture(autouse=True) +async def skip_setup_integration(): + """Override autouse setup from conftest.""" + yield + + +@pytest.mark.asyncio +async def test_async_update_options_listener_reload_called_once(hass, monkeypatch): + entry = MockConfigEntry(domain=DOMAIN, entry_id="test_entry", data={}) + + calls = [] + + async def fake_reload(entry_id): + calls.append(entry_id) + + monkeypatch.setattr(hass.config_entries, "async_reload", fake_reload) + + await _async_update_options_listener(hass, entry) + + assert calls == [entry.entry_id] From 11f3a187a2af181a8607d7cdcc14b25dcc34e599 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 20 Jul 2025 19:25:32 +0200 Subject: [PATCH 07/30] Add test for ConfigEntryNotReady when sensor unavailable --- tests/test_init_unload.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/tests/test_init_unload.py b/tests/test_init_unload.py index 01280bb..e86c41f 100644 --- a/tests/test_init_unload.py +++ b/tests/test_init_unload.py @@ -1,7 +1,16 @@ -from custom_components.simple_pid_controller import ( - async_unload_entry, -) -from custom_components.simple_pid_controller.const import DOMAIN +import logging + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from homeassistant.exceptions import ConfigEntryNotReady + +from custom_components.simple_pid_controller import async_setup_entry, async_unload_entry +from custom_components.simple_pid_controller.const import DOMAIN, CONF_SENSOR_ENTITY_ID, CONF_NAME + +@pytest.fixture +async def skip_setup_integration(): + """Override autouse setup so tests can call setup manually.""" + yield async def test_setup_and_unload_entry(hass, config_entry): @@ -30,3 +39,23 @@ async def test_setup_and_unload_entry(hass, config_entry): # hass Data should be gone assert DOMAIN not in hass.data + + +@pytest.mark.usefixtures("skip_setup_integration") +@pytest.mark.asyncio +async def test_setup_entry_not_ready_when_sensor_missing(hass, caplog): + """async_setup_entry should raise when the sensor state is missing.""" + + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="PID_MISSING", + title="Missing Sensor", + data={CONF_SENSOR_ENTITY_ID: "sensor.missing", CONF_NAME: "Missing"}, + ) + entry.add_to_hass(hass) + + caplog.set_level(logging.WARNING) + with pytest.raises(ConfigEntryNotReady): + await async_setup_entry(hass, entry) + + assert "Sensor sensor.missing not ready" in caplog.text From d7660795de78009d85f6ba671e5fc21f5595e891 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 20 Jul 2025 19:27:47 +0200 Subject: [PATCH 08/30] Update precommit.yml --- .github/workflows/precommit.yml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/precommit.yml b/.github/workflows/precommit.yml index c45911d..b0d219b 100644 --- a/.github/workflows/precommit.yml +++ b/.github/workflows/precommit.yml @@ -1,5 +1,8 @@ name: Run Pre-Commit +permissions: + contents: write + on: push: branches: @@ -11,9 +14,6 @@ on: - main workflow_dispatch: -permissions: - contents: write - jobs: test: env: @@ -28,7 +28,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: "3.12" cache: 'pip' # caching pip dependencies - name: Install dependencies @@ -39,4 +39,21 @@ jobs: - name: Run pre-commit hooks run: | - pre-commit run --all-files + # Pre-commit exits with code 1 when it makes changes. We don't want + # the workflow to fail in that case, so ignore the exit code. + pre-commit run --all-files || true + + - name: Commit and push changes + if: success() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "No changes to commit" + else + git commit -m "chore: apply pre-commit fixes" + git push + fi From 7e1678423924ea08e7983a37c4f539a46648bf20 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 6 Aug 2025 08:32:55 +0200 Subject: [PATCH 09/30] Update workflow naming --- .github/workflows/hacs.yml | 2 +- .github/workflows/hassfest.yml | 1 + .github/workflows/label-issues.yml | 1 + .github/workflows/precommit.yml | 6 ++++-- .github/workflows/pytest.yml | 5 +++-- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/hacs.yml b/.github/workflows/hacs.yml index 138dcc3..bb82ed0 100644 --- a/.github/workflows/hacs.yml +++ b/.github/workflows/hacs.yml @@ -9,12 +9,12 @@ on: schedule: - cron: "0 0 * * *" - permissions: contents: read jobs: validate-hacs: + name: "HACS" runs-on: ubuntu-latest steps: - name: HACS Validation diff --git a/.github/workflows/hassfest.yml b/.github/workflows/hassfest.yml index 28f9b6c..8934329 100644 --- a/.github/workflows/hassfest.yml +++ b/.github/workflows/hassfest.yml @@ -16,6 +16,7 @@ permissions: jobs: hassfest: + name: "Hassfest" runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml index 4d9693c..ebf753d 100644 --- a/.github/workflows/label-issues.yml +++ b/.github/workflows/label-issues.yml @@ -7,6 +7,7 @@ on: - edited jobs: label_issues: + name: "Automatically Label Issues" runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/precommit.yml b/.github/workflows/precommit.yml index b0d219b..af88ea9 100644 --- a/.github/workflows/precommit.yml +++ b/.github/workflows/precommit.yml @@ -1,4 +1,4 @@ -name: Run Pre-Commit +name: Run Code Quality Check permissions: contents: write @@ -16,6 +16,7 @@ on: jobs: test: + name: "Code Quality Check" env: PYTHONPATH: . runs-on: ubuntu-latest @@ -55,5 +56,6 @@ jobs: echo "No changes to commit" else git commit -m "chore: apply pre-commit fixes" - git push + BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" + git push origin "HEAD:${BRANCH}" fi diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a8bb832..aef0d2a 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -16,6 +16,7 @@ permissions: jobs: test: + name: "Pytest" env: PYTHONPATH: . runs-on: ubuntu-latest @@ -28,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.13" + python-version: "3.12" cache: 'pip' # caching pip dependencies - name: Install dependencies @@ -37,4 +38,4 @@ jobs: pip install -r requirements.txt - name: Run tests - run: pytest --maxfail=1 --disable-warnings -q \ No newline at end of file + run: pytest --maxfail=1 --disable-warnings -q From d0fc27ec46a3261b9ddba7be3700a7878c5ba3bb Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 6 Aug 2025 08:36:22 +0200 Subject: [PATCH 10/30] Update requirements.txt --- requirements.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index c9f53af..cd22981 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ asyncio -pytest==8.3.5 +simple-pid==2.0.1 +pytest pytest-cov pytest-homeassistant-custom-component -simple-pid==2.0.1 +syrupy From 4fdb63c13046b9cc8ca7366331bc8e5f4b4245e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 Aug 2025 06:52:37 +0000 Subject: [PATCH 11/30] chore: apply pre-commit fixes --- tests/test_init_unload.py | 12 ++++++++++-- tests/test_sensor.py | 1 - tests/test_unload_listeners.py | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_init_unload.py b/tests/test_init_unload.py index e86c41f..3b66fe3 100644 --- a/tests/test_init_unload.py +++ b/tests/test_init_unload.py @@ -4,8 +4,16 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from homeassistant.exceptions import ConfigEntryNotReady -from custom_components.simple_pid_controller import async_setup_entry, async_unload_entry -from custom_components.simple_pid_controller.const import DOMAIN, CONF_SENSOR_ENTITY_ID, CONF_NAME +from custom_components.simple_pid_controller import ( + async_setup_entry, + async_unload_entry, +) +from custom_components.simple_pid_controller.const import ( + DOMAIN, + CONF_SENSOR_ENTITY_ID, + CONF_NAME, +) + @pytest.fixture async def skip_setup_integration(): diff --git a/tests/test_sensor.py b/tests/test_sensor.py index bc1cbb7..082139f 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -399,4 +399,3 @@ async def test_update_pid_adjusts_update_interval(hass, config_entry, monkeypatc sample_time = 15 await coordinator.update_method() assert coordinator.update_interval == timedelta(seconds=sample_time) - diff --git a/tests/test_unload_listeners.py b/tests/test_unload_listeners.py index 9d29e5b..4b640f3 100644 --- a/tests/test_unload_listeners.py +++ b/tests/test_unload_listeners.py @@ -18,6 +18,7 @@ async def test_listeners_removed_after_unload(hass, config_entry, monkeypatch): def fake_listen(self, event, callback): def unsub(): called.append(True) + created.append(unsub) return unsub From 2a13a93f887940332f9c33f73cca91de0c8276ee Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 12 Aug 2025 08:01:50 +0200 Subject: [PATCH 12/30] refactor: reuse dummy pid fixture --- tests/conftest.py | 31 ++++++++++++++++++++++ tests/test_sensor.py | 62 +++++--------------------------------------- 2 files changed, 37 insertions(+), 56 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4e1e8ff..7faedeb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,37 @@ from homeassistant.const import CONF_NAME +@pytest.fixture +def dummy_pid_class(): + """Return a Dummy PID class used for testing.""" + + class DummyPID: + def __init__( + self, kp=0, ki=0, kd=0, setpoint=0, sample_time=None, auto_mode=False + ): + self.Kp = kp + self.Ki = ki + self.Kd = kd + self.setpoint = setpoint + self.sample_time = sample_time + self.auto_mode = auto_mode + 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 + + return DummyPID + + @pytest.fixture(autouse=True) def _enable_custom_integrations(enable_custom_integrations): """Enable loading of custom integrations in custom_components/""" # noqa: F811 diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 082139f..25c4ec5 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -153,39 +153,12 @@ async def test_update_pid_raises_on_missing_input(hass, config_entry): @pytest.mark.asyncio async def test_update_pid_output_limits_none_when_windup_protection_disabled( - monkeypatch, hass, config_entry + monkeypatch, hass, config_entry, dummy_pid_class ): """Test output_limits (None, None)""" - # Dummy PID class - class DummyPID: - def __init__( - self, kp=0, ki=0, kd=0, setpoint=0, sample_time=None, auto_mode=False - ): - self.Kp = kp - self.Ki = ki - self.Kd = kd - self.setpoint = setpoint - self.sample_time = sample_time - self.auto_mode = auto_mode - 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 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) + monkeypatch.setattr(sensor_module, "PID", dummy_pid_class) # init handle handle = config_entry.runtime_data.handle @@ -266,36 +239,13 @@ async def fake_get_last_state(): @pytest.mark.asyncio -async def test_update_pid_invalid_start_mode_defaults(monkeypatch, hass, config_entry): +async def test_update_pid_invalid_start_mode_defaults( + monkeypatch, hass, config_entry, dummy_pid_class +): """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, auto_mode=False - ): - self.Kp = kp - self.Ki = ki - self.Kd = kd - self.setpoint = setpoint - self.sample_time = sample_time - self.auto_mode = auto_mode - 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) + monkeypatch.setattr(sensor_module, "PID", dummy_pid_class) # Prepare the handle handle = config_entry.runtime_data.handle From 68221bc05f44cef0b311d9d804b0c754603568e9 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 12 Aug 2025 08:03:24 +0200 Subject: [PATCH 13/30] refactor tests integration setup --- tests/conftest.py | 2 +- tests/test_diagnostics.py | 2 ++ tests/test_init_unload.py | 8 +------- tests/test_number.py | 6 ++++++ tests/test_select.py | 4 ++++ tests/test_sensor.py | 9 +++++++++ tests/test_switch.py | 2 ++ tests/test_unload_listeners.py | 7 +------ tests/test_update_options_listener.py | 6 ------ 9 files changed, 26 insertions(+), 20 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4e1e8ff..0eb5b05 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ def _enable_custom_integrations(enable_custom_integrations): """Enable loading of custom integrations in custom_components/""" # noqa: F811 -@pytest.fixture(autouse=True) +@pytest.fixture async def setup_integration(hass, config_entry): """Set up the integration automatically for each test.""" assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index e0a9088..0c7dc69 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1,3 +1,4 @@ +import pytest from custom_components.simple_pid_controller.diagnostics import ( async_get_config_entry_diagnostics, ) @@ -12,6 +13,7 @@ ) +@pytest.mark.usefixtures("setup_integration") async def test_config_entry_diagnostics(hass, config_entry): """Test that diagnostics returns correct data for config entry.""" result = await async_get_config_entry_diagnostics(hass, config_entry) diff --git a/tests/test_init_unload.py b/tests/test_init_unload.py index 3b66fe3..1601484 100644 --- a/tests/test_init_unload.py +++ b/tests/test_init_unload.py @@ -15,12 +15,7 @@ ) -@pytest.fixture -async def skip_setup_integration(): - """Override autouse setup so tests can call setup manually.""" - yield - - +@pytest.mark.usefixtures("setup_integration") async def test_setup_and_unload_entry(hass, config_entry): """Test setting up and tearing down the entry.""" # runtime_data should exist… @@ -49,7 +44,6 @@ async def test_setup_and_unload_entry(hass, config_entry): assert DOMAIN not in hass.data -@pytest.mark.usefixtures("skip_setup_integration") @pytest.mark.asyncio async def test_setup_entry_not_ready_when_sensor_missing(hass, caplog): """async_setup_entry should raise when the sensor state is missing.""" diff --git a/tests/test_number.py b/tests/test_number.py index fc1d058..b0d942a 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -14,6 +14,7 @@ ) +@pytest.mark.usefixtures("setup_integration") async def test_number_platform(hass, config_entry): """Check that all Number entities from PID_NUMBER_ENTITIES are created.""" @@ -21,6 +22,7 @@ async def test_number_platform(hass, config_entry): assert len(numbers) == len(PID_NUMBER_ENTITIES) + len(CONTROL_NUMBER_ENTITIES) +@pytest.mark.usefixtures("setup_integration") @pytest.mark.parametrize("desc", PID_NUMBER_ENTITIES) async def test_number_entity_attributes(hass, config_entry, desc): entity_id = f"number.{config_entry.entry_id}_{desc['key']}" @@ -42,6 +44,7 @@ async def test_number_entity_attributes(hass, config_entry, desc): # assert state.name == desc.get("name", state.name) +@pytest.mark.usefixtures("setup_integration") @pytest.mark.parametrize( "last_value, expected", [ @@ -69,6 +72,7 @@ async def fake_get_last_number_data(): assert num.native_value == expected +@pytest.mark.usefixtures("setup_integration") async def test_async_added_to_hass_clamps_control_value( hass, config_entry, monkeypatch ): @@ -105,6 +109,7 @@ async def fake_last_within(): assert num.native_value == mid +@pytest.mark.usefixtures("setup_integration") @pytest.mark.parametrize( "clazz, desc, sample_value", [ @@ -133,6 +138,7 @@ def fake_write_state(): assert write_calls, "async_write_ha_state was not called" +@pytest.mark.usefixtures("setup_integration") @pytest.mark.parametrize( "invalid_key, expected_min, expected_max", [ diff --git a/tests/test_select.py b/tests/test_select.py index 6cbfe26..251d940 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -8,6 +8,7 @@ ) +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_pid_start_modes(hass, config_entry): """Check start modes.""" @@ -61,6 +62,7 @@ async def test_pid_start_modes(hass, config_entry): ) +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_async_select_option_applies_only_valid_options( hass, config_entry, monkeypatch @@ -96,6 +98,7 @@ async def test_async_select_option_applies_only_valid_options( ), "async_write_ha_state should not be called for an invalid option" +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio @pytest.mark.parametrize( "last_state, expected_option", @@ -132,6 +135,7 @@ async def fake_get_last_state(): assert select._attr_current_option == expected_option +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_async_added_to_hass_invalid_last_state(hass, config_entry, monkeypatch): """Ensure invalid last state does not override default option.""" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 082139f..46ce4ab 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -11,6 +11,7 @@ from custom_components.simple_pid_controller import sensor as sensor_module +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_pid_output_and_contributions_update(hass, config_entry): """Test that PID output and contribution sensors update on Home Assistant start.""" @@ -50,6 +51,7 @@ async def test_pid_output_and_contributions_update(hass, config_entry): assert float(state.state) != 0 +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_pid_contribution_native_value_rounding_and_none(hass, config_entry): """Test that PIDContributionSensor.native_value rounds correctly and returns None for unknown key.""" @@ -91,6 +93,7 @@ async def test_pid_contribution_native_value_rounding_and_none(hass, config_entr assert sensor_none.native_value is None +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_listeners_trigger_refresh_sensor(hass, config_entry, monkeypatch): """Lines 131-132: coordinator.async_request_refresh called on sensor state change.""" @@ -133,6 +136,7 @@ async def test_listeners_trigger_refresh_sensor(hass, config_entry, monkeypatch) ), "Coordinator.async_request_refresh was not called on sensor state change" +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_update_pid_raises_on_missing_input(hass, config_entry): """Line 47: update_pid should raise ValueError when input sensor unavailable.""" @@ -151,6 +155,7 @@ async def test_update_pid_raises_on_missing_input(hass, config_entry): assert "Input sensor not available" in str(excinfo.value) +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_update_pid_output_limits_none_when_windup_protection_disabled( monkeypatch, hass, config_entry @@ -225,6 +230,7 @@ def __call__(self, input_value): assert pid.output_limits == (None, None) +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio @pytest.mark.parametrize( "restored_state, expected_last_known", @@ -265,6 +271,7 @@ async def fake_get_last_state(): assert handle.last_known_output == expected_last_known +@pytest.mark.usefixtures("setup_integration") @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.""" @@ -338,6 +345,7 @@ def __call__(self, input_value): assert pid._output == 42.0 +@pytest.mark.usefixtures("setup_integration") 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 @@ -363,6 +371,7 @@ def test_pid_contribution_error_when_input_or_setpoint_none(hass, config_entry): assert sensor.native_value == 0 +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_update_pid_adjusts_update_interval(hass, config_entry, monkeypatch): """Ensure coordinator.update_interval updates when sample_time changes.""" diff --git a/tests/test_switch.py b/tests/test_switch.py index e659abd..bfede62 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -5,6 +5,7 @@ ) +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_switch_operations(hass, config_entry): """Test that each switch entity is created and can be toggled on/off.""" @@ -34,6 +35,7 @@ async def test_switch_operations(hass, config_entry): @pytest.mark.asyncio @pytest.mark.parametrize("last_state, expected", [("on", True), ("off", False)]) +@pytest.mark.usefixtures("setup_integration") async def test_async_added_to_hass_restores_previous_state( hass, config_entry, monkeypatch, last_state, expected ): diff --git a/tests/test_unload_listeners.py b/tests/test_unload_listeners.py index 4b640f3..2602a3d 100644 --- a/tests/test_unload_listeners.py +++ b/tests/test_unload_listeners.py @@ -3,12 +3,7 @@ from custom_components.simple_pid_controller.sensor import async_setup_entry -@pytest.fixture(autouse=True) -async def skip_setup_integration(): - """Override autouse setup from conftest; tests will setup manually.""" - yield - - +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_listeners_removed_after_unload(hass, config_entry, monkeypatch): """Ensure state change listeners are unsubscribed when the entry unloads.""" diff --git a/tests/test_update_options_listener.py b/tests/test_update_options_listener.py index 053d17b..bd1db57 100644 --- a/tests/test_update_options_listener.py +++ b/tests/test_update_options_listener.py @@ -5,12 +5,6 @@ from custom_components.simple_pid_controller import _async_update_options_listener -@pytest.fixture(autouse=True) -async def skip_setup_integration(): - """Override autouse setup from conftest.""" - yield - - @pytest.mark.asyncio async def test_async_update_options_listener_reload_called_once(hass, monkeypatch): entry = MockConfigEntry(domain=DOMAIN, entry_id="test_entry", data={}) From f95c20f736b6d07554b69a9a1d04419a4cb701c6 Mon Sep 17 00:00:00 2001 From: Anssi Hannula Date: Wed, 13 Aug 2025 16:27:37 +0300 Subject: [PATCH 14/30] Increase range of coefficients E.g. on systems with very slowly moving process variable a large Kd and small Ki may be needed. Increase the range at both ends to accommodate such cases. --- .../simple_pid_controller/number.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index f920960..e941aa0 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -33,9 +33,9 @@ "name": "Kp", "key": "kp", "unit": "", - "min": -100.0, - "max": 100.0, - "step": 0.001, + "min": -1000.0, + "max": 1000.0, + "step": 0.0001, "default": 1.0, "entity_category": EntityCategory.CONFIG, }, @@ -43,9 +43,9 @@ "name": "Ki", "key": "ki", "unit": "", - "min": -100.0, - "max": 100.0, - "step": 0.001, + "min": -1000.0, + "max": 1000.0, + "step": 0.0001, "default": 0.1, "entity_category": EntityCategory.CONFIG, }, @@ -53,9 +53,9 @@ "name": "Kd", "key": "kd", "unit": "", - "min": -100.0, - "max": 100.0, - "step": 0.001, + "min": -1000.0, + "max": 1000.0, + "step": 0.0001, "default": 0.05, "entity_category": EntityCategory.CONFIG, }, From beb73ce3c4942d47ae8adf2578dbe99710540768 Mon Sep 17 00:00:00 2001 From: Anssi Hannula Date: Wed, 13 Aug 2025 16:33:37 +0300 Subject: [PATCH 15/30] Increase maximum sample time to 600 seconds On systems with slowly-changing process variable a longer sample time may be warranted. Increase the maximum allowed value from 60 seconds to 600 seconds. --- custom_components/simple_pid_controller/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index e941aa0..7504fa0 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -64,7 +64,7 @@ "key": "sample_time", "unit": "s", "min": 0.01, - "max": 60.0, + "max": 600.0, "step": 0.01, "default": 10.0, "entity_category": EntityCategory.CONFIG, From a636a2349d2e515aac1f796f9f523ff1197bbc6e Mon Sep 17 00:00:00 2001 From: Anssi Hannula Date: Wed, 13 Aug 2025 16:36:14 +0300 Subject: [PATCH 16/30] Increase resolution of diagnostics sensors Increase resolution of diagnostics sensors to improve their usability in systems where the process variable movements are small and slow. --- custom_components/simple_pid_controller/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index d6a9214..b529036 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -262,4 +262,4 @@ def native_value(self): "error": error, "pid_i_delta": contributions[3], }.get(self._key) - return round(value, 2) if value is not None else None + return round(value, 3) if value is not None else None From 96cc3c3e26ff6c91b1bca5767d3d002844524eaf Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 13 Aug 2025 18:57:16 +0200 Subject: [PATCH 17/30] chore: fix issue labeler workflow --- .github/workflows/label-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml index ebf753d..ae6d9ec 100644 --- a/.github/workflows/label-issues.yml +++ b/.github/workflows/label-issues.yml @@ -12,7 +12,7 @@ jobs: permissions: issues: write steps: - - uses: github/issue-labeler@v3 + - uses: github/issue-labeler@v3.4 with: repo-token: ${{ secrets.GITHUB_TOKEN }} configuration-path: .github/labeler.yml From 85edb17b5108dd328169b31f15f9ab6062df51e5 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 13 Aug 2025 19:05:26 +0200 Subject: [PATCH 18/30] Register PIDContributionSensor in test --- tests/test_sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 699d38a..b9ab995 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -78,6 +78,10 @@ async def test_pid_contribution_native_value_rounding_and_none(hass, config_entr f"sensor.{config_entry.entry_id}_{key}", coordinator, ) + sensor.entity_id = f"sensor.{config_entry.entry_id.lower()}_{key}" + await sensor.async_added_to_hass() + await sensor.async_update_ha_state(force_refresh=True) + assert sensor._handle is handle assert sensor.native_value == expected @@ -89,9 +93,15 @@ async def test_pid_contribution_native_value_rounding_and_none(hass, config_entr "sensor.{config_entry.entry_id}_pid_x_contrib", coordinator, ) + sensor_none.entity_id = "sensor.pid_x_contrib" + await sensor_none.async_added_to_hass() + await sensor_none.async_update_ha_state(force_refresh=True) + assert sensor_none._handle is handle assert sensor_none.native_value is None + await coordinator.async_shutdown() + @pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio From 18207324de3772553cf4a2a8ba5f707f2380ced3 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 13 Aug 2025 19:20:36 +0200 Subject: [PATCH 19/30] Fix test to match precision --- tests/test_sensor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b9ab995..6c4ea4b 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -62,11 +62,11 @@ async def test_pid_contribution_native_value_rounding_and_none(hass, config_entr # Map contribution keys to expected values mapping = [ - ("pid_p_contrib", round(0.1234, 2)), - ("pid_i_contrib", round(1.9876, 2)), - ("pid_d_contrib", round(2.5555, 2)), + ("pid_p_contrib", round(0.1234, 3)), + ("pid_i_contrib", round(1.9876, 3)), + ("pid_d_contrib", round(2.5555, 3)), ("error", -25), - ("pid_i_delta", round(3.3789, 2)), + ("pid_i_delta", round(3.3789, 3)), ("unknown_key", None), # Should return None ] From 1ab424c737e1477ed5e3524e8cd92f8841b11cbf Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 13 Aug 2025 19:44:29 +0200 Subject: [PATCH 20/30] test: cover last known value start mode --- tests/test_sensor.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 6c4ea4b..0ee75fb 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -305,6 +305,44 @@ async def test_update_pid_invalid_start_mode_defaults( assert pid._output == 42.0 +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.asyncio +async def test_update_pid_uses_last_known_value( + monkeypatch, hass, config_entry, dummy_pid_class +): + """Line 78: start_mode 'Last known value' uses handle.last_known_output.""" + + monkeypatch.setattr(sensor_module, "PID", dummy_pid_class) + + handle = config_entry.runtime_data.handle + handle.last_known_output = 73.5 + handle.last_contributions = (0.0, 0.0, 0.0, 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, + }[key] + handle.get_switch = lambda key: True + handle.get_select = lambda key: "Last known value" if key == "start_mode" else None + + entities = [] + await sensor_module.async_setup_entry( + hass, config_entry, lambda e: entities.extend(e) + ) + coordinator = entities[0].coordinator + await coordinator.update_method() + + pid = handle.pid + assert pid.auto_mode is True + assert pid._output == handle.last_known_output + + @pytest.mark.usefixtures("setup_integration") 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.""" From 4bbeedc646df03989d78a8cd60cb827d7d6bc422 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 14 Aug 2025 19:59:42 +0200 Subject: [PATCH 21/30] Add service to set PID output --- README.md | 15 +++- .../simple_pid_controller/__init__.py | 73 ++++++++++++++++++- .../simple_pid_controller/services.yaml | 25 +++++++ .../translations/en.json | 25 ++++++- .../translations/nl.json | 17 +++++ tests/conftest.py | 3 + tests/test_sensor.py | 18 ++++- tests/test_service.py | 62 ++++++++++++++++ 8 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 custom_components/simple_pid_controller/services.yaml create mode 100644 tests/test_service.py diff --git a/README.md b/README.md index a6954ed..3bc4f3f 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,18 @@ Here's an example output showing the controller responding to a setpoint: --- -## 🔧 Service Actions -This Integration does **not** expose any custom services. All interactions are performed via UI-based entities. +## 🔧 Service Actions +The integration provides a `simple_pid_controller.set_output` service to adjust the controller output directly. + +### `simple_pid_controller.set_output` +| Field | Description | +|-------|-------------| +| `entity_id` | PID output sensor entity to control | +| `preset` | Optional preset: `zero_start`, `last_known_value`, or `startup_value` | +| `value` | Optional manual value between configured `Output Min` and `Output Max` | + +- When **Auto Mode** is off, the last output value is updated to the chosen value. +- When **Auto Mode** is on, the PID restarts from the new value and the coordinator refreshes. + diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 9799d12..0ead002 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -5,10 +5,13 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import entity_registry as er from dataclasses import dataclass +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + from .coordinator import PIDDataCoordinator from .const import ( @@ -34,6 +37,19 @@ Platform.SELECT, ] +SERVICE_SET_OUTPUT = "set_output" +ATTR_VALUE = "value" +ATTR_PRESET = "preset" +PRESET_OPTIONS = ["zero_start", "last_known_value", "startup_value"] + +SET_OUTPUT_SCHEMA = vol.Schema( + { + vol.Required("entity_id"): cv.entity_id, + vol.Exclusive(ATTR_VALUE, "target"): vol.Coerce(float), + vol.Exclusive(ATTR_PRESET, "target"): vol.In(PRESET_OPTIONS), + } +) + @dataclass class MyData: @@ -68,6 +84,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: CONF_SENSOR_ENTITY_ID, entry.data.get(CONF_SENSOR_ENTITY_ID) ) self.last_contributions = (None, None, None) # (P, I, D) + self.last_known_output = None def _get_entity_id(self, platform: str, key: str) -> str | None: """Lookup the real entity_id in the registry by unique_id == '_'.""" @@ -145,6 +162,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: handle = PIDDeviceHandle(hass, entry) entry.runtime_data = MyData(handle=handle) + if not hass.services.has_service(DOMAIN, SERVICE_SET_OUTPUT): + async def async_set_output(call: ServiceCall) -> None: + entity_id: str = call.data["entity_id"] + preset: str | None = call.data.get(ATTR_PRESET) + value: float | None = call.data.get(ATTR_VALUE) + + registry = er.async_get(hass) + ent = registry.async_get(entity_id) + if ent is None: + raise HomeAssistantError(f"Unknown entity {entity_id}") + config_entry = hass.config_entries.async_get_entry(ent.config_entry_id) + if config_entry is None or config_entry.runtime_data is None: + raise HomeAssistantError("PID controller not loaded") + dev_handle: PIDDeviceHandle = config_entry.runtime_data.handle + out_min = dev_handle.get_number("output_min") or 0.0 + out_max = dev_handle.get_number("output_max") or 0.0 + + if preset is None and value is None: + raise HomeAssistantError("Either preset or value required") + + if preset is not None: + if preset == "zero_start": + target = 0.0 + elif preset == "last_known_value": + target = dev_handle.last_known_output or 0.0 + elif preset == "startup_value": + target = dev_handle.get_number("starting_output") or 0.0 + else: + raise HomeAssistantError("Invalid preset") + else: + target = value + if target is None: + raise HomeAssistantError("Value required") + if target < out_min or target > out_max: + raise HomeAssistantError( + f"Value {target} out of range {out_min}-{out_max}" + ) + + dev_handle.last_known_output = target + coordinator: PIDDataCoordinator = config_entry.runtime_data.coordinator + if dev_handle.pid.auto_mode: + dev_handle.pid.set_auto_mode(True, target) + await coordinator.async_request_refresh() + else: + coordinator.async_set_updated_data(target) + + hass.services.async_register( + DOMAIN, SERVICE_SET_OUTPUT, async_set_output, schema=SET_OUTPUT_SCHEMA + ) + # register updatelistener for optionsflow entry.async_on_unload(entry.add_update_listener(_async_update_options_listener)) @@ -157,6 +224,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): # reset runtime_data zodat tests slagen entry.runtime_data = None + if not hass.config_entries.async_entries(DOMAIN): + hass.services.async_remove(DOMAIN, SERVICE_SET_OUTPUT) return unload_ok diff --git a/custom_components/simple_pid_controller/services.yaml b/custom_components/simple_pid_controller/services.yaml new file mode 100644 index 0000000..c7eb0ac --- /dev/null +++ b/custom_components/simple_pid_controller/services.yaml @@ -0,0 +1,25 @@ +set_output: + name: Set PID output + description: Set or reset the PID controller output. + fields: + entity_id: + name: PID output sensor + description: Target PID controller entity. + selector: + entity: + domain: sensor + preset: + name: Preset + description: Use a preset output: zero_start, last_known_value or startup_value. + selector: + select: + options: + - zero_start + - last_known_value + - startup_value + value: + name: Value + description: Manual output value between output_min and output_max. + selector: + number: + step: 0.1 diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index da0dbb1..6e12af2 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -42,7 +42,7 @@ "range_min_max": "Minimum must be lower than maximum." } }, - "entity": { + "entity": { "number": { "kp": { "name": "Kp" @@ -75,6 +75,23 @@ "current_value": { "name": "Current Value" } - } - } -} + } + } + , + "services": { + "set_output": { + "name": "Set PID output", + "description": "Set or reset the PID controller output.", + "fields": { + "preset": { + "name": "Preset", + "description": "Use a preset output: zero_start, last_known_value or startup_value." + }, + "value": { + "name": "Value", + "description": "Manual output value between output_min and output_max." + } + } + } + } +} diff --git a/custom_components/simple_pid_controller/translations/nl.json b/custom_components/simple_pid_controller/translations/nl.json index 8fa4b95..0d73623 100644 --- a/custom_components/simple_pid_controller/translations/nl.json +++ b/custom_components/simple_pid_controller/translations/nl.json @@ -76,4 +76,21 @@ } } } + , + "services": { + "set_output": { + "name": "Stel PID-uitgang in", + "description": "Stelt de uitgang van de PID-regelaar in of reset deze.", + "fields": { + "preset": { + "name": "Voorinstelling", + "description": "Kies een startwaarde: zero_start, last_known_value of startup_value." + }, + "value": { + "name": "Waarde", + "description": "Handmatige uitgangswaarde binnen output_min en output_max." + } + } + } + } } diff --git a/tests/conftest.py b/tests/conftest.py index bcc5dc2..1faa706 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -48,6 +48,9 @@ async def setup_integration(hass, config_entry): """Set up the integration automatically for each test.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + yield + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() @pytest.fixture diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 0ee75fb..b5b2a27 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -8,6 +8,7 @@ ) 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 async_unload_entry from custom_components.simple_pid_controller import sensor as sensor_module @@ -145,6 +146,8 @@ async def test_listeners_trigger_refresh_sensor(hass, config_entry, monkeypatch) called ), "Coordinator.async_request_refresh was not called on sensor state change" + await async_unload_entry(hass, config_entry) + @pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio @@ -164,6 +167,8 @@ async def test_update_pid_raises_on_missing_input(hass, config_entry): await coordinator.update_method() assert "Input sensor not available" in str(excinfo.value) + await async_unload_entry(hass, config_entry) + @pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio @@ -193,10 +198,7 @@ async def test_update_pid_output_limits_none_when_windup_protection_disabled( handle.get_switch = lambda key: False if key == "windup_protection" else True 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 + coordinator = config_entry.runtime_data.coordinator await coordinator.update_method() # Extract thee DummyPID from closure of update_method @@ -212,6 +214,8 @@ async def test_update_pid_output_limits_none_when_windup_protection_disabled( # check output_limits assert pid.output_limits == (None, None) + await coordinator.async_shutdown() + @pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio @@ -304,6 +308,8 @@ async def test_update_pid_invalid_start_mode_defaults( assert pid.auto_mode is True assert pid._output == 42.0 + await async_unload_entry(hass, config_entry) + @pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio @@ -342,6 +348,8 @@ async def test_update_pid_uses_last_known_value( assert pid.auto_mode is True assert pid._output == handle.last_known_output + await async_unload_entry(hass, config_entry) + @pytest.mark.usefixtures("setup_integration") def test_pid_contribution_error_when_input_or_setpoint_none(hass, config_entry): @@ -406,3 +414,5 @@ async def test_update_pid_adjusts_update_interval(hass, config_entry, monkeypatc sample_time = 15 await coordinator.update_method() assert coordinator.update_interval == timedelta(seconds=sample_time) + + await async_unload_entry(hass, config_entry) diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..c07491c --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,62 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock +from custom_components.simple_pid_controller.const import DOMAIN + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.asyncio +async def test_set_output_manual(hass, config_entry): + handle = config_entry.runtime_data.handle + coordinator = config_entry.runtime_data.coordinator + + await hass.services.async_call( + DOMAIN, + "set_output", + { + "entity_id": f"sensor.{config_entry.entry_id.lower()}_pid_output", + "value": 0.5, + }, + blocking=True, + ) + + assert handle.last_known_output == 0.5 + assert coordinator.data == 0.5 + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.asyncio +async def test_set_output_preset_startup(monkeypatch, hass, config_entry): + handle = config_entry.runtime_data.handle + coordinator = config_entry.runtime_data.coordinator + + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": f"number.{config_entry.entry_id.lower()}_startup_value", + "value": 0.4, + }, + blocking=True, + ) + await hass.async_block_till_done() + assert handle.get_number("starting_output") == 0.4 + + handle.pid.auto_mode = True + mock_set = MagicMock() + handle.pid.set_auto_mode = mock_set + mock_refresh = AsyncMock() + coordinator.async_request_refresh = mock_refresh + + await hass.services.async_call( + DOMAIN, + "set_output", + { + "entity_id": f"sensor.{config_entry.entry_id.lower()}_pid_output", + "preset": "startup_value", + }, + blocking=True, + ) + + assert handle.last_known_output == 0.4 + mock_set.assert_called_with(True, 0.4) + assert mock_refresh.await_count == 1 From 21ff6326cbf83b42372a239f8351d6b6a61b6a09 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 14 Aug 2025 18:00:59 +0000 Subject: [PATCH 22/30] chore: apply pre-commit fixes --- custom_components/simple_pid_controller/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 0ead002..bba0706 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -163,6 +163,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.runtime_data = MyData(handle=handle) if not hass.services.has_service(DOMAIN, SERVICE_SET_OUTPUT): + async def async_set_output(call: ServiceCall) -> None: entity_id: str = call.data["entity_id"] preset: str | None = call.data.get(ATTR_PRESET) From 0e7ad29f35ad342145338ab8e05c63f639349a2d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 14 Aug 2025 20:11:40 +0200 Subject: [PATCH 23/30] Fix output service schema and validation --- custom_components/simple_pid_controller/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 0ead002..de877e1 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -45,8 +45,8 @@ SET_OUTPUT_SCHEMA = vol.Schema( { vol.Required("entity_id"): cv.entity_id, - vol.Exclusive(ATTR_VALUE, "target"): vol.Coerce(float), - vol.Exclusive(ATTR_PRESET, "target"): vol.In(PRESET_OPTIONS), + vol.Optional(ATTR_VALUE): vol.Coerce(float), + vol.Optional(ATTR_PRESET): vol.In(PRESET_OPTIONS), } ) @@ -179,7 +179,9 @@ async def async_set_output(call: ServiceCall) -> None: out_min = dev_handle.get_number("output_min") or 0.0 out_max = dev_handle.get_number("output_max") or 0.0 - if preset is None and value is None: + if (preset is None and value is None) or ( + preset is not None and value is not None + ): raise HomeAssistantError("Either preset or value required") if preset is not None: From 383f421c48e2819821d4af0c40cc4f48ceb17881 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 14 Aug 2025 20:11:47 +0200 Subject: [PATCH 24/30] Toggle auto mode off before re-enabling --- custom_components/simple_pid_controller/__init__.py | 1 + tests/test_service.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index bba0706..693f71e 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -204,6 +204,7 @@ async def async_set_output(call: ServiceCall) -> None: dev_handle.last_known_output = target coordinator: PIDDataCoordinator = config_entry.runtime_data.coordinator if dev_handle.pid.auto_mode: + dev_handle.pid.set_auto_mode(False) dev_handle.pid.set_auto_mode(True, target) await coordinator.async_request_refresh() else: diff --git a/tests/test_service.py b/tests/test_service.py index c07491c..bced9b5 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,5 +1,5 @@ import pytest -from unittest.mock import MagicMock, AsyncMock +from unittest.mock import MagicMock, AsyncMock, call from custom_components.simple_pid_controller.const import DOMAIN @@ -58,5 +58,5 @@ async def test_set_output_preset_startup(monkeypatch, hass, config_entry): ) assert handle.last_known_output == 0.4 - mock_set.assert_called_with(True, 0.4) + assert mock_set.call_args_list == [call(False), call(True, 0.4)] assert mock_refresh.await_count == 1 From 511b5a02a19b8c66b17188b215e89a24b67b4c58 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 14 Aug 2025 20:20:13 +0200 Subject: [PATCH 25/30] Move entity to target in services --- custom_components/simple_pid_controller/services.yaml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/custom_components/simple_pid_controller/services.yaml b/custom_components/simple_pid_controller/services.yaml index c7eb0ac..786df08 100644 --- a/custom_components/simple_pid_controller/services.yaml +++ b/custom_components/simple_pid_controller/services.yaml @@ -1,13 +1,11 @@ set_output: name: Set PID output description: Set or reset the PID controller output. + target: + entity: + integration: simple_pid_controller + domain: sensor fields: - entity_id: - name: PID output sensor - description: Target PID controller entity. - selector: - entity: - domain: sensor preset: name: Preset description: Use a preset output: zero_start, last_known_value or startup_value. From d25003f6c9a299a6e1896f47fe73511286eb0d78 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 14 Aug 2025 20:28:40 +0200 Subject: [PATCH 26/30] fix services yaml description --- custom_components/simple_pid_controller/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/simple_pid_controller/services.yaml b/custom_components/simple_pid_controller/services.yaml index 786df08..f63cafd 100644 --- a/custom_components/simple_pid_controller/services.yaml +++ b/custom_components/simple_pid_controller/services.yaml @@ -8,7 +8,7 @@ set_output: fields: preset: name: Preset - description: Use a preset output: zero_start, last_known_value or startup_value. + description: "Use a preset output: zero_start, last_known_value or startup_value." selector: select: options: From 67a2d5f7a31ed72e6a6f077ef2e8e8fa18915702 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 15 Aug 2025 07:29:27 +0200 Subject: [PATCH 27/30] Update __init__.py --- custom_components/simple_pid_controller/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 6fcb3f7..e15e9cb 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -208,6 +208,7 @@ async def async_set_output(call: ServiceCall) -> None: if dev_handle.pid.auto_mode: dev_handle.pid.set_auto_mode(False) dev_handle.pid.set_auto_mode(True, target) + coordinator.async_set_updated_data(target) await coordinator.async_request_refresh() else: coordinator.async_set_updated_data(target) From 1651e085429b617553d6a8c186c67fcf6a654353 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 15 Aug 2025 07:34:49 +0200 Subject: [PATCH 28/30] Allow set_output service to use target --- README.md | 12 ++++++++++- .../simple_pid_controller/__init__.py | 8 +++++--- tests/test_service.py | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3bc4f3f..2bd9518 100644 --- a/README.md +++ b/README.md @@ -191,12 +191,22 @@ The integration provides a `simple_pid_controller.set_output` service to adjust ### `simple_pid_controller.set_output` | Field | Description | |-------|-------------| -| `entity_id` | PID output sensor entity to control | +| `entity_id` | PID output sensor entity to control (may be provided via `target`) | | `preset` | Optional preset: `zero_start`, `last_known_value`, or `startup_value` | | `value` | Optional manual value between configured `Output Min` and `Output Max` | - When **Auto Mode** is off, the last output value is updated to the chosen value. - When **Auto Mode** is on, the PID restarts from the new value and the coordinator refreshes. +Example using `target`: + +```yaml +action: simple_pid_controller.set_output +target: + entity_id: sensor.spid_x_pid_output +data: + value: 200 +``` + diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 6fcb3f7..2f69b87 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -4,7 +4,7 @@ import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import Platform, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -44,7 +44,7 @@ SET_OUTPUT_SCHEMA = vol.Schema( { - vol.Required("entity_id"): cv.entity_id, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, vol.Optional(ATTR_VALUE): vol.Coerce(float), vol.Optional(ATTR_PRESET): vol.In(PRESET_OPTIONS), } @@ -165,7 +165,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not hass.services.has_service(DOMAIN, SERVICE_SET_OUTPUT): async def async_set_output(call: ServiceCall) -> None: - entity_id: str = call.data["entity_id"] + entity_id: str | None = call.data.get(ATTR_ENTITY_ID) + if entity_id is None: + raise HomeAssistantError("entity_id is required") preset: str | None = call.data.get(ATTR_PRESET) value: float | None = call.data.get(ATTR_VALUE) diff --git a/tests/test_service.py b/tests/test_service.py index bced9b5..6d1a659 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -23,6 +23,26 @@ async def test_set_output_manual(hass, config_entry): assert coordinator.data == 0.5 +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.asyncio +async def test_set_output_manual_target(hass, config_entry): + handle = config_entry.runtime_data.handle + coordinator = config_entry.runtime_data.coordinator + + await hass.services.async_call( + DOMAIN, + "set_output", + {"value": 0.5}, + target={ + "entity_id": f"sensor.{config_entry.entry_id.lower()}_pid_output", + }, + blocking=True, + ) + + assert handle.last_known_output == 0.5 + assert coordinator.data == 0.5 + + @pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_set_output_preset_startup(monkeypatch, hass, config_entry): From 57a0d32cc0353f80b76ad52e79b7635e36deb56a Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 15 Aug 2025 07:45:00 +0200 Subject: [PATCH 29/30] Handle target entity IDs correctly --- README.md | 13 +++++- .../simple_pid_controller/__init__.py | 13 ++++-- tests/test_service.py | 41 +++++++++++++++++++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3bc4f3f..8945f0d 100644 --- a/README.md +++ b/README.md @@ -191,12 +191,23 @@ The integration provides a `simple_pid_controller.set_output` service to adjust ### `simple_pid_controller.set_output` | Field | Description | |-------|-------------| -| `entity_id` | PID output sensor entity to control | +| `entity_id` | PID output sensor entity to control (may be provided via `target`) | | `preset` | Optional preset: `zero_start`, `last_known_value`, or `startup_value` | | `value` | Optional manual value between configured `Output Min` and `Output Max` | - When **Auto Mode** is off, the last output value is updated to the chosen value. - When **Auto Mode** is on, the PID restarts from the new value and the coordinator refreshes. +- Exactly one PID output sensor entity must be targeted. + +Example using `target`: + +```yaml +service: simple_pid_controller.set_output +target: + entity_id: sensor.spid_x_pid_output +data: + value: 200 +``` diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 6fcb3f7..fdf9e40 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -4,7 +4,7 @@ import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import Platform, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -42,9 +42,8 @@ ATTR_PRESET = "preset" PRESET_OPTIONS = ["zero_start", "last_known_value", "startup_value"] -SET_OUTPUT_SCHEMA = vol.Schema( +SET_OUTPUT_SCHEMA = cv.make_entity_service_schema( { - vol.Required("entity_id"): cv.entity_id, vol.Optional(ATTR_VALUE): vol.Coerce(float), vol.Optional(ATTR_PRESET): vol.In(PRESET_OPTIONS), } @@ -165,7 +164,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not hass.services.has_service(DOMAIN, SERVICE_SET_OUTPUT): async def async_set_output(call: ServiceCall) -> None: - entity_id: str = call.data["entity_id"] + entity_id: str | list[str] | None = call.data.get(ATTR_ENTITY_ID) + if entity_id is None: + raise HomeAssistantError("entity_id is required") + if isinstance(entity_id, list): + if len(entity_id) != 1: + raise HomeAssistantError("Exactly one entity_id is required") + entity_id = entity_id[0] preset: str | None = call.data.get(ATTR_PRESET) value: float | None = call.data.get(ATTR_VALUE) diff --git a/tests/test_service.py b/tests/test_service.py index bced9b5..49505a7 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,5 +1,6 @@ import pytest from unittest.mock import MagicMock, AsyncMock, call +from homeassistant.exceptions import HomeAssistantError from custom_components.simple_pid_controller.const import DOMAIN @@ -23,6 +24,46 @@ async def test_set_output_manual(hass, config_entry): assert coordinator.data == 0.5 +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.asyncio +async def test_set_output_manual_target(hass, config_entry): + handle = config_entry.runtime_data.handle + coordinator = config_entry.runtime_data.coordinator + + await hass.services.async_call( + DOMAIN, + "set_output", + {"value": 0.5}, + target={ + "entity_id": [ + f"sensor.{config_entry.entry_id.lower()}_pid_output", + ] + }, + blocking=True, + ) + + assert handle.last_known_output == 0.5 + assert coordinator.data == 0.5 + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.asyncio +async def test_set_output_multiple_targets_error(hass, config_entry): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + "set_output", + {"value": 0.5}, + target={ + "entity_id": [ + f"sensor.{config_entry.entry_id.lower()}_pid_output", + f"sensor.{config_entry.entry_id.lower()}_pid_output", + ] + }, + blocking=True, + ) + + @pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio async def test_set_output_preset_startup(monkeypatch, hass, config_entry): From 5ee0daa3f2304c3bec50a30dc748d7288a1b3892 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 15 Aug 2025 07:59:39 +0200 Subject: [PATCH 30/30] Update PID output when auto mode disabled --- custom_components/simple_pid_controller/__init__.py | 3 +++ tests/test_service.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 63d50ea..8fac8bf 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -218,6 +218,9 @@ async def async_set_output(call: ServiceCall) -> None: await coordinator.async_request_refresh() else: coordinator.async_set_updated_data(target) + # Update the internal PID output when in manual mode so that + # future calls to the controller return the newly set target. + dev_handle.pid._last_output = target hass.services.async_register( DOMAIN, SERVICE_SET_OUTPUT, async_set_output, schema=SET_OUTPUT_SCHEMA diff --git a/tests/test_service.py b/tests/test_service.py index 49505a7..aa74657 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -22,6 +22,9 @@ async def test_set_output_manual(hass, config_entry): assert handle.last_known_output == 0.5 assert coordinator.data == 0.5 + # Ensure that the PID controller's internal output is updated when + # auto mode is disabled. + assert handle.pid._last_output == 0.5 @pytest.mark.usefixtures("setup_integration") @@ -44,6 +47,7 @@ async def test_set_output_manual_target(hass, config_entry): assert handle.last_known_output == 0.5 assert coordinator.data == 0.5 + assert handle.pid._last_output == 0.5 @pytest.mark.usefixtures("setup_integration")