diff --git a/.github/workflows/label-prs.yml b/.github/workflows/label-prs.yml index 54030ba..e4a6bd3 100644 --- a/.github/workflows/label-prs.yml +++ b/.github/workflows/label-prs.yml @@ -1,7 +1,7 @@ name: Label PRs on: - pull_request: + pull_request_target: types: [opened, synchronize, reopened] permissions: diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index b64a162..1e1a339 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -25,10 +25,12 @@ CONF_INPUT_RANGE_MAX, CONF_OUTPUT_RANGE_MIN, CONF_OUTPUT_RANGE_MAX, + CONF_SETPOINT_STEP, DEFAULT_INPUT_RANGE_MIN, DEFAULT_INPUT_RANGE_MAX, DEFAULT_OUTPUT_RANGE_MIN, DEFAULT_OUTPUT_RANGE_MAX, + DEFAULT_SETPOINT_STEP, ) _LOGGER = logging.getLogger(__name__) @@ -145,6 +147,9 @@ async def async_step_init( current_output_max = self.config_entry.options.get( CONF_OUTPUT_RANGE_MAX, DEFAULT_OUTPUT_RANGE_MAX ) + current_setpoint_step = self.config_entry.options.get( + CONF_SETPOINT_STEP, DEFAULT_SETPOINT_STEP + ) options_schema = vol.Schema( { @@ -168,6 +173,19 @@ async def async_step_init( CONF_OUTPUT_RANGE_MAX, default=current_output_max, ): vol.Coerce(float), + vol.Optional( + CONF_SETPOINT_STEP, + default=current_setpoint_step, + ): selector( + { + "number": { + "min": 0.01, + "max": 10.0, + "step": 0.01, + "mode": "box", + } + } + ), } ) diff --git a/custom_components/simple_pid_controller/const.py b/custom_components/simple_pid_controller/const.py index 592b1b5..49dfa3b 100644 --- a/custom_components/simple_pid_controller/const.py +++ b/custom_components/simple_pid_controller/const.py @@ -11,8 +11,10 @@ CONF_INPUT_RANGE_MAX = "input_range_max" CONF_OUTPUT_RANGE_MIN = "output_range_min" CONF_OUTPUT_RANGE_MAX = "output_range_max" +CONF_SETPOINT_STEP = "setpoint_step" DEFAULT_INPUT_RANGE_MIN = 0.0 DEFAULT_INPUT_RANGE_MAX = 100.0 DEFAULT_OUTPUT_RANGE_MIN = 0.0 DEFAULT_OUTPUT_RANGE_MAX = 100.0 +DEFAULT_SETPOINT_STEP = 0.01 diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index b429ceb..cf64ac3 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -3,12 +3,13 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.number import RestoreNumber from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import BasePIDEntity from .const import ( @@ -16,10 +17,12 @@ CONF_INPUT_RANGE_MAX, CONF_OUTPUT_RANGE_MIN, CONF_OUTPUT_RANGE_MAX, + CONF_SETPOINT_STEP, DEFAULT_INPUT_RANGE_MIN, DEFAULT_INPUT_RANGE_MAX, DEFAULT_OUTPUT_RANGE_MIN, DEFAULT_OUTPUT_RANGE_MAX, + DEFAULT_SETPOINT_STEP, ) # Coordinator is used to centralize the data updates @@ -28,6 +31,18 @@ _LOGGER = logging.getLogger(__name__) +SETPOINT_STEP_ENTITY = { + "name": "Setpoint Step", + "key": "setpoint_step", + "unit": "", + "min": 0.01, + "max": 10.0, + "step": 0.01, + "default": DEFAULT_SETPOINT_STEP, + "entity_category": EntityCategory.CONFIG, +} + + PID_NUMBER_ENTITIES = [ { "name": "Kp", @@ -116,6 +131,7 @@ async def async_setup_entry( entities = [ ControlParameterNumber(hass, entry, desc) for desc in CONTROL_NUMBER_ENTITIES ] + entities.append(SetpointStepNumber(hass, entry, SETPOINT_STEP_ENTITY)) async_add_entities(entities) @@ -162,14 +178,19 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None: self._attr_icon = "mdi:ray-vertex" self._attr_mode = "box" self._attr_native_unit_of_measurement = desc["unit"] - self._attr_native_step = desc["step"] self._attr_native_value = desc["default"] self._attr_entity_category = desc["entity_category"] self._key = desc["key"] - # Compute range limits based on key + # Compute range limits based on key. opts = entry.options or {} data = entry.data or {} + + if self._key == "setpoint": + self._attr_native_step = opts.get(CONF_SETPOINT_STEP, DEFAULT_SETPOINT_STEP) + else: + self._attr_native_step = desc["step"] + input_range_min = opts.get( CONF_INPUT_RANGE_MIN, data.get(CONF_INPUT_RANGE_MIN, DEFAULT_INPUT_RANGE_MIN), @@ -208,7 +229,6 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None: self._attr_native_min_value = min_val self._attr_native_max_value = max_val - self._attr_native_step = desc.get("step", 1.0) # Initialize current value if self._key == "setpoint": @@ -243,3 +263,38 @@ def native_value(self) -> float: async def async_set_native_value(self, value: float) -> None: self._attr_native_value = value self.async_write_ha_state() + + +class SetpointStepNumber(RestoreNumber): + """Editable number entity for the setpoint step size.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, desc: dict[str, Any] + ) -> None: + BasePIDEntity.__init__(self, hass, entry, desc["key"], desc["name"]) + RestoreNumber.__init__(self) + + opts = entry.options or {} + + self._attr_icon = "mdi:tune-vertical" + self._attr_mode = "box" + self._attr_native_unit_of_measurement = desc["unit"] + self._attr_native_min_value = desc["min"] + self._attr_native_max_value = desc["max"] + self._attr_native_step = desc["step"] + self._attr_native_value = opts.get(CONF_SETPOINT_STEP, desc["default"]) + self._attr_entity_category = desc["entity_category"] + + @property + def native_value(self) -> float: + return self._attr_native_value + + async def async_set_native_value(self, value: float) -> None: + self._attr_native_value = value + updated_options = dict(self._entry.options) + updated_options[CONF_SETPOINT_STEP] = value + self.hass.config_entries.async_update_entry( + self._entry, + options=updated_options, + ) + await self.hass.config_entries.async_reload(self._entry.entry_id) diff --git a/custom_components/simple_pid_controller/strings.json b/custom_components/simple_pid_controller/strings.json index 6bdd0a9..adb021b 100644 --- a/custom_components/simple_pid_controller/strings.json +++ b/custom_components/simple_pid_controller/strings.json @@ -28,7 +28,8 @@ "input_range_min": "Minimum Input Range", "input_range_max": "Maximum Input Range", "output_range_min": "Minimum Output Range", - "output_range_max": "Maximum Output Range" + "output_range_max": "Maximum Output Range", + "setpoint_step": "Setpoint Step" } } } diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index 6e12af2..159fb1a 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -29,15 +29,19 @@ "init": { "title": "Edit PID Controller Options", "description": "Modify the sensor entity and range used by the controller.", - "data": { - "sensor_entity_id": "Sensor Entity", - "input_range_min": "Minimum Input Range", - "input_range_max": "Maximum Input Range", - "output_range_min": "Minimum Output Range", - "output_range_max": "Maximum Output Range" - } - } - }, + "data": { + "sensor_entity_id": "Sensor Entity", + "input_range_min": "Minimum Input Range", + "input_range_max": "Maximum Input Range", + "output_range_min": "Minimum Output Range", + "output_range_max": "Maximum Output Range", + "setpoint_step": "Setpoint Step" + }, + "data_description": { + "setpoint_step": "Increment size for the setpoint picker." + } + } + }, "error": { "range_min_max": "Minimum must be lower than maximum." } diff --git a/custom_components/simple_pid_controller/translations/nl.json b/custom_components/simple_pid_controller/translations/nl.json index 0d73623..b08cd6d 100644 --- a/custom_components/simple_pid_controller/translations/nl.json +++ b/custom_components/simple_pid_controller/translations/nl.json @@ -33,7 +33,11 @@ "input_range_min": "Minimum Input Bereik", "input_range_max": "Maximum Input Bereik", "output_range_min": "Minimum Output Bereik", - "output_range_max": "Maximum Output Bereik" + "output_range_max": "Maximum Output Bereik", + "setpoint_step": "Stappgrootte Setpoint" + }, + "data_description": { + "setpoint_step": "Stapgrootte voor de setpoint-kiezer." } } }, diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 1d28955..e9fd657 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -3,6 +3,7 @@ from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResultType +from custom_components.simple_pid_controller.const import CONF_SETPOINT_STEP from custom_components.simple_pid_controller.const import ( DOMAIN, CONF_NAME, @@ -142,6 +143,7 @@ def test_async_get_options_flow(): CONF_INPUT_RANGE_MAX: 10.0, CONF_OUTPUT_RANGE_MIN: 1.0, CONF_OUTPUT_RANGE_MAX: 10.0, + CONF_SETPOINT_STEP: 0.5, }, None, ), @@ -152,6 +154,7 @@ def test_async_get_options_flow(): CONF_INPUT_RANGE_MAX: 5.0, CONF_OUTPUT_RANGE_MIN: 1.0, CONF_OUTPUT_RANGE_MAX: 10.0, + CONF_SETPOINT_STEP: 0.5, }, {"base": "input_range_min_max"}, ), @@ -162,6 +165,7 @@ def test_async_get_options_flow(): CONF_INPUT_RANGE_MAX: 10.0, CONF_OUTPUT_RANGE_MIN: 10.0, CONF_OUTPUT_RANGE_MAX: 5.0, + CONF_SETPOINT_STEP: 0.5, }, {"base": "output_range_min_max"}, ), diff --git a/tests/test_number.py b/tests/test_number.py index b0d942a..96890dd 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -1,12 +1,22 @@ -import pytest import logging +from types import SimpleNamespace + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.simple_pid_controller.number import ( PID_NUMBER_ENTITIES, CONTROL_NUMBER_ENTITIES, PIDParameterNumber, ControlParameterNumber, + SETPOINT_STEP_ENTITY, + SetpointStepNumber, ) from custom_components.simple_pid_controller.const import ( + CONF_NAME, + CONF_SENSOR_ENTITY_ID, + CONF_SETPOINT_STEP, + DEFAULT_SETPOINT_STEP, + DOMAIN, DEFAULT_INPUT_RANGE_MIN, DEFAULT_INPUT_RANGE_MAX, DEFAULT_OUTPUT_RANGE_MIN, @@ -19,7 +29,7 @@ async def test_number_platform(hass, config_entry): """Check that all Number entities from PID_NUMBER_ENTITIES are created.""" numbers = hass.states.async_entity_ids("number") - assert len(numbers) == len(PID_NUMBER_ENTITIES) + len(CONTROL_NUMBER_ENTITIES) + assert len(numbers) == len(PID_NUMBER_ENTITIES) + len(CONTROL_NUMBER_ENTITIES) + 1 @pytest.mark.usefixtures("setup_integration") @@ -163,3 +173,92 @@ async def test_controlparameter_number_unexpected_key( assert f"Unknown PID key '{invalid_key}'. Using default values:" in caplog.text assert num._attr_native_min_value == expected_min assert num._attr_native_max_value == expected_max + + +def test_setpoint_uses_configured_step(hass): + """Test that the setpoint control uses the configured step option.""" + setpoint_desc = next( + desc for desc in CONTROL_NUMBER_ENTITIES if desc["key"] == "setpoint" + ) + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="PID3", + title="Test PID Controller", + data={CONF_SENSOR_ENTITY_ID: "sensor.test_input", CONF_NAME: "PID3"}, + options={CONF_SETPOINT_STEP: 0.5}, + ) + entry.runtime_data = SimpleNamespace(handle=SimpleNamespace(name="PID3")) + + num = ControlParameterNumber(hass, entry, setpoint_desc) + + assert num._attr_native_step == 0.5 + + +def test_setpoint_defaults_to_default_step(hass): + """Test that the setpoint control falls back to the default step.""" + setpoint_desc = next( + desc for desc in CONTROL_NUMBER_ENTITIES if desc["key"] == "setpoint" + ) + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="PID4", + title="Test PID Controller", + data={CONF_SENSOR_ENTITY_ID: "sensor.test_input", CONF_NAME: "PID4"}, + ) + entry.runtime_data = SimpleNamespace(handle=SimpleNamespace(name="PID4")) + + num = ControlParameterNumber(hass, entry, setpoint_desc) + + assert num._attr_native_step == DEFAULT_SETPOINT_STEP + + +def test_setpoint_step_entity_uses_configured_value(hass): + """Test that the setpoint step config entity reflects the saved option.""" + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="PID5", + title="Test PID Controller", + data={CONF_SENSOR_ENTITY_ID: "sensor.test_input", CONF_NAME: "PID5"}, + options={CONF_SETPOINT_STEP: 0.25}, + ) + entry.runtime_data = SimpleNamespace(handle=SimpleNamespace(name="PID5")) + + num = SetpointStepNumber(hass, entry, SETPOINT_STEP_ENTITY) + + assert num._attr_native_min_value == 0.01 + assert num._attr_native_max_value == 10.0 + assert num._attr_native_step == 0.01 + assert num.native_value == 0.25 + + +async def test_setpoint_step_entity_updates_entry_options_and_reloads( + hass, monkeypatch +): + """Test that changing setpoint step persists options and reloads the entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="PID6", + title="Test PID Controller", + data={CONF_SENSOR_ENTITY_ID: "sensor.test_input", CONF_NAME: "PID6"}, + ) + entry.runtime_data = SimpleNamespace(handle=SimpleNamespace(name="PID6")) + num = SetpointStepNumber(hass, entry, SETPOINT_STEP_ENTITY) + + update_calls = [] + reload_calls = [] + + def fake_update_entry(target_entry, *, options): + update_calls.append((target_entry, options)) + + async def fake_reload(entry_id): + reload_calls.append(entry_id) + return True + + monkeypatch.setattr(hass.config_entries, "async_update_entry", fake_update_entry) + monkeypatch.setattr(hass.config_entries, "async_reload", fake_reload) + + await num.async_set_native_value(0.25) + + assert num.native_value == 0.25 + assert update_calls == [(entry, {CONF_SETPOINT_STEP: 0.25})] + assert reload_calls == [entry.entry_id]