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/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 9a3db9f..ae6d9ec 100644 --- a/.github/workflows/label-issues.yml +++ b/.github/workflows/label-issues.yml @@ -7,10 +7,13 @@ on: - edited jobs: label_issues: + name: "Automatically Label Issues" runs-on: ubuntu-latest permissions: issues: write steps: - - uses: peaceiris/actions-issue-labeler@v1 + - uses: github/issue-labeler@v3.4 with: - github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + diff --git a/.github/workflows/precommit.yml b/.github/workflows/precommit.yml index c45911d..af88ea9 100644 --- a/.github/workflows/precommit.yml +++ b/.github/workflows/precommit.yml @@ -1,5 +1,8 @@ -name: Run Pre-Commit +name: Run Code Quality Check +permissions: + contents: write + on: push: branches: @@ -11,11 +14,9 @@ on: - main workflow_dispatch: -permissions: - contents: write - jobs: test: + name: "Code Quality Check" 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 @@ -39,4 +40,22 @@ 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" + 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 diff --git a/README.md b/README.md index a6954ed..51832aa 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,39 @@ 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 (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 +``` + +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 9799d12..8fac8bf 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -4,11 +4,14 @@ 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.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 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 = cv.make_entity_service_schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional(ATTR_VALUE): vol.Coerce(float), + vol.Optional(ATTR_PRESET): 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,70 @@ 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 | 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) + + 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) or ( + preset is not None and value is not 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(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) + # 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 + ) + # register updatelistener for optionsflow entry.async_on_unload(entry.add_update_listener(_async_update_options_listener)) @@ -157,6 +238,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/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/number.py b/custom_components/simple_pid_controller/number.py index f920960..7504fa0 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, }, @@ -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, diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 5b4d5c6..b529036 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( @@ -204,16 +207,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 +243,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): @@ -261,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 diff --git a/custom_components/simple_pid_controller/services.yaml b/custom_components/simple_pid_controller/services.yaml new file mode 100644 index 0000000..f63cafd --- /dev/null +++ b/custom_components/simple_pid_controller/services.yaml @@ -0,0 +1,23 @@ +set_output: + name: Set PID output + description: Set or reset the PID controller output. + target: + entity: + integration: simple_pid_controller + domain: sensor + fields: + 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/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 diff --git a/tests/conftest.py b/tests/conftest.py index 4e1e8ff..1faa706 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,16 +7,50 @@ 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 -@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) 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_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 01280bb..1601484 100644 --- a/tests/test_init_unload.py +++ b/tests/test_init_unload.py @@ -1,9 +1,21 @@ +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 +from custom_components.simple_pid_controller.const import ( + DOMAIN, + CONF_SENSOR_ENTITY_ID, + CONF_NAME, +) +@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… @@ -30,3 +42,22 @@ async def test_setup_and_unload_entry(hass, config_entry): # hass Data should be gone assert DOMAIN not in hass.data + + +@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 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 5425762..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", @@ -130,3 +133,28 @@ async def fake_get_last_state(): # Assert 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.""" + 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 diff --git a/tests/test_sensor.py b/tests/test_sensor.py index dfd2b11..b5b2a27 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -8,9 +8,11 @@ ) 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 +@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 +52,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.""" @@ -60,11 +63,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 ] @@ -76,7 +79,11 @@ 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 + 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 # Unknown key should return None @@ -87,10 +94,17 @@ 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 + 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 async def test_listeners_trigger_refresh_sensor(hass, config_entry, monkeypatch): """Lines 131-132: coordinator.async_request_refresh called on sensor state change.""" @@ -132,7 +146,10 @@ 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 async def test_update_pid_raises_on_missing_input(hass, config_entry): """Line 47: update_pid should raise ValueError when input sensor unavailable.""" @@ -150,42 +167,18 @@ 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 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 @@ -205,10 +198,7 @@ def __call__(self, input_value): 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 @@ -224,7 +214,10 @@ def __call__(self, input_value): # check output_limits assert pid.output_limits == (None, None) + await coordinator.async_shutdown() + +@pytest.mark.usefixtures("setup_integration") @pytest.mark.asyncio @pytest.mark.parametrize( "restored_state, expected_last_known", @@ -265,37 +258,15 @@ 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): +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 @@ -337,7 +308,50 @@ def __call__(self, input_value): 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 +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 + + 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): """Line 258: native_value for 'error' should be 0 when input or setpoint is None.""" handle = config_entry.runtime_data.handle @@ -350,7 +364,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 +373,46 @@ 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 + + +@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.""" + + 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) + + 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..aa74657 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,107 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, call +from homeassistant.exceptions import HomeAssistantError +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 + # 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") +@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 + assert handle.pid._last_output == 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): + 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 + assert mock_set.call_args_list == [call(False), call(True, 0.4)] + assert mock_refresh.await_count == 1 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 new file mode 100644 index 0000000..2602a3d --- /dev/null +++ b/tests/test_unload_listeners.py @@ -0,0 +1,29 @@ +import pytest + +from custom_components.simple_pid_controller.sensor import async_setup_entry + + +@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.""" + 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) diff --git a/tests/test_update_options_listener.py b/tests/test_update_options_listener.py new file mode 100644 index 0000000..bd1db57 --- /dev/null +++ b/tests/test_update_options_listener.py @@ -0,0 +1,21 @@ +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.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]