From 68c78a12d3e4eafa1651aa73d874189dfd1300b5 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 14 May 2025 14:55:33 +0200 Subject: [PATCH 01/57] initial scaling --- .../simple_pid_controller/__init__.py | 4 +- .../simple_pid_controller/config_flow.py | 6 +- .../simple_pid_controller/const.py | 9 +- .../simple_pid_controller/number.py | 88 ++++++++++++++----- 4 files changed, 84 insertions(+), 23 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index ad53667..2b8def0 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -9,7 +9,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, CONF_NAME, CONF_SENSOR_ENTITY_ID +from .const import DOMAIN, CONF_NAME, CONF_SENSOR_ENTITY_ID, CONF_RANGE_MIN, CONF_RANGE_MAX, DEFAULT_RANGE_MIN, DEFAULT_RANGE_MAX _LOGGER = logging.getLogger(__name__) @@ -23,6 +23,8 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.hass = hass self.entry = entry self.name = entry.data.get(CONF_NAME) + self.range_min = entry.options.get(CONF_RANGE_MIN, entry.data.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN)) + self.range_max = entry.options.get(CONF_RANGE_MAX, entry.data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX)) self.sensor_entity_id = entry.options.get( CONF_SENSOR_ENTITY_ID, entry.data.get(CONF_SENSOR_ENTITY_ID) ) diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index 349031e..0de4bee 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.core import callback from homeassistant.helpers.selector import selector -from .const import DOMAIN, CONF_NAME, CONF_SENSOR_ENTITY_ID +from .const import DOMAIN, CONF_NAME, CONF_SENSOR_ENTITY_ID, CONF_RANGE_MIN, CONF_RANGE_MAX, DEFAULT_RANGE_MIN, DEFAULT_RANGE_MAX _LOGGER = logging.getLogger(__name__) @@ -57,6 +57,8 @@ async def async_step_user( vol.Required(CONF_SENSOR_ENTITY_ID): selector( {"entity": {"domain": "sensor"}} ), + vol.Optional(CONF_RANGE_MIN, default=DEFAULT_RANGE_MIN): vol.Coerce(float), + vol.Optional(CONF_RANGE_MAX, default=DEFAULT_RANGE_MAX): vol.Coerce(float), } ), ) @@ -86,6 +88,8 @@ async def async_step_init( vol.Required(CONF_SENSOR_ENTITY_ID, default=current_sensor): selector( {"entity": {"domain": "sensor"}} ), + vol.Required(CONF_RANGE_MIN, default=current.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN)): vol.Coerce(float), + vol.Required(CONF_RANGE_MAX, default=current.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX)): vol.Coerce(float), } ) diff --git a/custom_components/simple_pid_controller/const.py b/custom_components/simple_pid_controller/const.py index 5dec9da..37e4f94 100644 --- a/custom_components/simple_pid_controller/const.py +++ b/custom_components/simple_pid_controller/const.py @@ -1,5 +1,12 @@ """Constants for the PID Controller integration.""" DOMAIN = "simple_pid_controller" -CONF_NAME = "name" + +CONF_NAME = "SPIDx" CONF_SENSOR_ENTITY_ID = "sensor_entity_id" + +CONF_RANGE_MIN = "range_min" +CONF_RANGE_MAX = "range_max" + +DEFAULT_RANGE_MIN = 0.0 +DEFAULT_RANGE_MAX = 100.0 \ No newline at end of file diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index a63443c..bd09b07 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -42,44 +42,41 @@ "default": 0.05, "entity_category": EntityCategory.CONFIG, }, + { + "name": "Sample Time", + "key": "sample_time", + "unit": "s", + "min": 0.01, + "max": 60.0, + "step": 0.01, + "default": 10.0, + "entity_category": EntityCategory.CONFIG, + }, +] + +CONTROL_NUMBER_ENTITIES = [ { "name": "Setpoint", "key": "setpoint", - "unit": "%", - "min": 0.0, - "max": 100.0, + "unit": "", "step": 1.0, - "default": 50.0, + "default": 0.5, "entity_category": None, }, { "name": "Output Min", "key": "output_min", "unit": "", - "min": -100.0, - "max": 0.0, "step": 1.0, - "default": -10.0, + "default": 0, "entity_category": EntityCategory.CONFIG, }, { "name": "Output Max", "key": "output_max", "unit": "", - "min": 0.0, - "max": 100.0, "step": 1.0, - "default": 10.0, - "entity_category": EntityCategory.CONFIG, - }, - { - "name": "Sample Time", - "key": "sample_time", - "unit": "s", - "min": 0.01, - "max": 60.0, - "step": 0.01, - "default": 10.0, + "default": 1, "entity_category": EntityCategory.CONFIG, }, ] @@ -90,9 +87,13 @@ async def async_setup_entry( ) -> None: handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] name = handle.name + entities = [PIDParameterNumber(entry, name, desc) for desc in PID_NUMBER_ENTITIES] async_add_entities(entities) + entities = [ControlParameterNumber(entry, name, desc) for desc in CONTROL_NUMBER_ENTITIES] + async_add_entities(entities) + class PIDParameterNumber(RestoreNumber): def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: @@ -126,3 +127,50 @@ 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 ControlParameterNumber(RestoreNumber): + def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: + opts = entry.options or {} + data = entry.data or {} + self._range_min = opts.get(CONF_RANGE_MIN, data.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN)) + self._range_max = opts.get(CONF_RANGE_MAX, data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX)) + + self._attr_name = f"{desc['name']}" + self._attr_has_entity_name = True + self._attr_unique_id = f"{entry.entry_id}_{desc['key']}" + self._attr_icon = "mdi:ray-vertex" + self._attr_mode = "box" + self._attr_native_unit_of_measurement = desc["unit"] + self._attr_native_min_value = self._range_min + self._attr_native_max_value = self._range_max + self._attr_native_step = desc["step"] + # a + (b - a) * f: + self._attr_native_value = self._range_min + (self._range_max + self._range_min) * float(desc["default"]) + self._attr_entity_category = desc["entity_category"] + + # Device-info + self._attr_device_info = { + "identifiers": {(DOMAIN, entry.entry_id)}, + "name": device_name, + } + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + if (last := await self.async_get_last_number_data()) is not None: + self._attr_native_value = last.native_value + + @property + def native_value(self) -> float: + return self._attr_native_value + + @property + def min_value(self) -> float: + return self._range_min + + @property + def max_value(self) -> float: + return self._range_max + + async def async_set_native_value(self, value: float) -> None: + self._attr_native_value = value + self.async_write_ha_state() \ No newline at end of file From e44769b32f0fe21c816a5691f62c289577ad0f8d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 14 May 2025 17:29:54 +0000 Subject: [PATCH 02/57] Linting fixes: modified: custom_components/simple_pid_controller/__init__.py modified: custom_components/simple_pid_controller/config_flow.py modified: custom_components/simple_pid_controller/const.py modified: custom_components/simple_pid_controller/number.py --- .../simple_pid_controller/__init__.py | 18 ++++++++++-- .../simple_pid_controller/config_flow.py | 28 ++++++++++++++---- .../simple_pid_controller/const.py | 2 +- .../simple_pid_controller/number.py | 29 ++++++++++++++----- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 2b8def0..ce8dcf3 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -9,7 +9,15 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from .const import DOMAIN, CONF_NAME, CONF_SENSOR_ENTITY_ID, CONF_RANGE_MIN, CONF_RANGE_MAX, DEFAULT_RANGE_MIN, DEFAULT_RANGE_MAX +from .const import ( + DOMAIN, + CONF_NAME, + CONF_SENSOR_ENTITY_ID, + CONF_RANGE_MIN, + CONF_RANGE_MAX, + DEFAULT_RANGE_MIN, + DEFAULT_RANGE_MAX, +) _LOGGER = logging.getLogger(__name__) @@ -23,8 +31,12 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.hass = hass self.entry = entry self.name = entry.data.get(CONF_NAME) - self.range_min = entry.options.get(CONF_RANGE_MIN, entry.data.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN)) - self.range_max = entry.options.get(CONF_RANGE_MAX, entry.data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX)) + self.range_min = entry.options.get( + CONF_RANGE_MIN, entry.data.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN) + ) + self.range_max = entry.options.get( + CONF_RANGE_MAX, entry.data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX) + ) self.sensor_entity_id = entry.options.get( CONF_SENSOR_ENTITY_ID, entry.data.get(CONF_SENSOR_ENTITY_ID) ) diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index 0de4bee..1d4929a 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -16,7 +16,15 @@ from homeassistant.core import callback from homeassistant.helpers.selector import selector -from .const import DOMAIN, CONF_NAME, CONF_SENSOR_ENTITY_ID, CONF_RANGE_MIN, CONF_RANGE_MAX, DEFAULT_RANGE_MIN, DEFAULT_RANGE_MAX +from .const import ( + DOMAIN, + CONF_NAME, + CONF_SENSOR_ENTITY_ID, + CONF_RANGE_MIN, + CONF_RANGE_MAX, + DEFAULT_RANGE_MIN, + DEFAULT_RANGE_MAX, +) _LOGGER = logging.getLogger(__name__) @@ -57,8 +65,12 @@ async def async_step_user( vol.Required(CONF_SENSOR_ENTITY_ID): selector( {"entity": {"domain": "sensor"}} ), - vol.Optional(CONF_RANGE_MIN, default=DEFAULT_RANGE_MIN): vol.Coerce(float), - vol.Optional(CONF_RANGE_MAX, default=DEFAULT_RANGE_MAX): vol.Coerce(float), + vol.Optional(CONF_RANGE_MIN, default=DEFAULT_RANGE_MIN): vol.Coerce( + float + ), + vol.Optional(CONF_RANGE_MAX, default=DEFAULT_RANGE_MAX): vol.Coerce( + float + ), } ), ) @@ -88,8 +100,14 @@ async def async_step_init( vol.Required(CONF_SENSOR_ENTITY_ID, default=current_sensor): selector( {"entity": {"domain": "sensor"}} ), - vol.Required(CONF_RANGE_MIN, default=current.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN)): vol.Coerce(float), - vol.Required(CONF_RANGE_MAX, default=current.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX)): vol.Coerce(float), + vol.Required( + CONF_RANGE_MIN, + default=current_sensor.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN), + ): vol.Coerce(float), + vol.Required( + CONF_RANGE_MAX, + default=current_sensor.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX), + ): vol.Coerce(float), } ) diff --git a/custom_components/simple_pid_controller/const.py b/custom_components/simple_pid_controller/const.py index 37e4f94..7d016a1 100644 --- a/custom_components/simple_pid_controller/const.py +++ b/custom_components/simple_pid_controller/const.py @@ -9,4 +9,4 @@ CONF_RANGE_MAX = "range_max" DEFAULT_RANGE_MIN = 0.0 -DEFAULT_RANGE_MAX = 100.0 \ No newline at end of file +DEFAULT_RANGE_MAX = 100.0 diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index bd09b07..fead747 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -9,7 +9,13 @@ from homeassistant.helpers.entity import EntityCategory from . import PIDDeviceHandle -from .const import DOMAIN +from .const import ( + DOMAIN, + CONF_RANGE_MIN, + CONF_RANGE_MAX, + DEFAULT_RANGE_MIN, + DEFAULT_RANGE_MAX, +) PID_NUMBER_ENTITIES = [ { @@ -91,7 +97,9 @@ async def async_setup_entry( entities = [PIDParameterNumber(entry, name, desc) for desc in PID_NUMBER_ENTITIES] async_add_entities(entities) - entities = [ControlParameterNumber(entry, name, desc) for desc in CONTROL_NUMBER_ENTITIES] + entities = [ + ControlParameterNumber(entry, name, desc) for desc in CONTROL_NUMBER_ENTITIES + ] async_add_entities(entities) @@ -128,12 +136,17 @@ async def async_set_native_value(self, value: float) -> None: self._attr_native_value = value self.async_write_ha_state() + class ControlParameterNumber(RestoreNumber): def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: opts = entry.options or {} data = entry.data or {} - self._range_min = opts.get(CONF_RANGE_MIN, data.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN)) - self._range_max = opts.get(CONF_RANGE_MAX, data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX)) + self._range_min = opts.get( + CONF_RANGE_MIN, data.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN) + ) + self._range_max = opts.get( + CONF_RANGE_MAX, data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX) + ) self._attr_name = f"{desc['name']}" self._attr_has_entity_name = True @@ -145,7 +158,9 @@ def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: self._attr_native_max_value = self._range_max self._attr_native_step = desc["step"] # a + (b - a) * f: - self._attr_native_value = self._range_min + (self._range_max + self._range_min) * float(desc["default"]) + self._attr_native_value = self._range_min + ( + self._range_max + self._range_min + ) * float(desc["default"]) self._attr_entity_category = desc["entity_category"] # Device-info @@ -170,7 +185,7 @@ def min_value(self) -> float: @property def max_value(self) -> float: return self._range_max - + async def async_set_native_value(self, value: float) -> None: self._attr_native_value = value - self.async_write_ha_state() \ No newline at end of file + self.async_write_ha_state() From e514d649030c944183ea2b48f9d63e039a28aacc Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 15 May 2025 05:38:18 +0000 Subject: [PATCH 03/57] Fixed Testing errors modified: custom_components/simple_pid_controller/number.py modified: custom_components/simple_pid_controller/sensor.py modified: custom_components/simple_pid_controller/switch.py modified: tests/conftest.py modified: tests/test_number.py --- custom_components/simple_pid_controller/number.py | 4 +--- custom_components/simple_pid_controller/sensor.py | 2 +- custom_components/simple_pid_controller/switch.py | 4 +--- tests/conftest.py | 4 +--- tests/test_number.py | 8 ++++++-- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index fead747..b5a9509 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -8,7 +8,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory -from . import PIDDeviceHandle from .const import ( DOMAIN, CONF_RANGE_MIN, @@ -91,8 +90,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name + name = entry.entry_id entities = [PIDParameterNumber(entry, name, desc) for desc in PID_NUMBER_ENTITIES] async_add_entities(entities) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index e6b4d99..49546fe 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( ) -> None: """Set up PID output and diagnostic sensors.""" handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name + name = entry.entry_id # Init PID with default values pid = PID(1.0, 0.1, 0.05, setpoint=50) diff --git a/custom_components/simple_pid_controller/switch.py b/custom_components/simple_pid_controller/switch.py index b644493..e72bcf8 100644 --- a/custom_components/simple_pid_controller/switch.py +++ b/custom_components/simple_pid_controller/switch.py @@ -7,7 +7,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory -from . import PIDDeviceHandle from .const import DOMAIN SWITCH_ENTITIES = [ @@ -23,8 +22,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name + name = entry.entry_id async_add_entities([PIDOptionSwitch(entry, name, desc) for desc in SWITCH_ENTITIES]) diff --git a/tests/conftest.py b/tests/conftest.py index a06e1d0..51ffc2c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,9 +35,7 @@ async def config_entry(hass, device_registry: DeviceRegistry): device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, - name={CONF_NAME}, - manufacturer="Test", - model="PID Controller", + name=entry.entry_id, ) return entry diff --git a/tests/test_number.py b/tests/test_number.py index bfa7588..55432eb 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -1,17 +1,21 @@ import pytest -from custom_components.simple_pid_controller.number import PID_NUMBER_ENTITIES +from custom_components.simple_pid_controller.number import ( + PID_NUMBER_ENTITIES, + CONTROL_NUMBER_ENTITIES, +) 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) + assert len(numbers) == len(PID_NUMBER_ENTITIES) + len(CONTROL_NUMBER_ENTITIES) @pytest.mark.parametrize("desc", PID_NUMBER_ENTITIES) async def test_number_entity_attributes(hass, config_entry, desc): # Build the entity_id from the entry and the key in the descriptor + print(config_entry.entry_id) entity_id = f"number.{config_entry.entry_id}_{desc['key']}" # Check that the entity exists From 928891aa9a46450edaab9e77ac8d5fc243400f2b Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 15 May 2025 05:38:18 +0000 Subject: [PATCH 04/57] Fixed Testing errors modified: custom_components/simple_pid_controller/number.py modified: custom_components/simple_pid_controller/sensor.py modified: custom_components/simple_pid_controller/switch.py modified: tests/conftest.py modified: tests/test_number.py --- custom_components/simple_pid_controller/number.py | 5 ++--- custom_components/simple_pid_controller/sensor.py | 2 +- custom_components/simple_pid_controller/switch.py | 4 +--- tests/conftest.py | 4 +--- tests/test_number.py | 8 ++++++-- 5 files changed, 11 insertions(+), 12 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index a63443c..a1c7e0b 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -8,7 +8,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory -from . import PIDDeviceHandle from .const import DOMAIN PID_NUMBER_ENTITIES = [ @@ -88,8 +87,8 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name + name = entry.entry_id + entities = [PIDParameterNumber(entry, name, desc) for desc in PID_NUMBER_ENTITIES] async_add_entities(entities) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index e6b4d99..49546fe 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( ) -> None: """Set up PID output and diagnostic sensors.""" handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name + name = entry.entry_id # Init PID with default values pid = PID(1.0, 0.1, 0.05, setpoint=50) diff --git a/custom_components/simple_pid_controller/switch.py b/custom_components/simple_pid_controller/switch.py index b644493..e72bcf8 100644 --- a/custom_components/simple_pid_controller/switch.py +++ b/custom_components/simple_pid_controller/switch.py @@ -7,7 +7,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory -from . import PIDDeviceHandle from .const import DOMAIN SWITCH_ENTITIES = [ @@ -23,8 +22,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name + name = entry.entry_id async_add_entities([PIDOptionSwitch(entry, name, desc) for desc in SWITCH_ENTITIES]) diff --git a/tests/conftest.py b/tests/conftest.py index a06e1d0..51ffc2c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,9 +35,7 @@ async def config_entry(hass, device_registry: DeviceRegistry): device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, - name={CONF_NAME}, - manufacturer="Test", - model="PID Controller", + name=entry.entry_id, ) return entry diff --git a/tests/test_number.py b/tests/test_number.py index bfa7588..55432eb 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -1,17 +1,21 @@ import pytest -from custom_components.simple_pid_controller.number import PID_NUMBER_ENTITIES +from custom_components.simple_pid_controller.number import ( + PID_NUMBER_ENTITIES, + CONTROL_NUMBER_ENTITIES, +) 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) + assert len(numbers) == len(PID_NUMBER_ENTITIES) + len(CONTROL_NUMBER_ENTITIES) @pytest.mark.parametrize("desc", PID_NUMBER_ENTITIES) async def test_number_entity_attributes(hass, config_entry, desc): # Build the entity_id from the entry and the key in the descriptor + print(config_entry.entry_id) entity_id = f"number.{config_entry.entry_id}_{desc['key']}" # Check that the entity exists From 9a5940eeb979baf33871c89458bcc9e0d7dfed0b Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 15 May 2025 16:22:58 +0000 Subject: [PATCH 05/57] modified: tests/test_number.py --- tests/test_number.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_number.py b/tests/test_number.py index 55432eb..9fbd98d 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -1,7 +1,7 @@ import pytest from custom_components.simple_pid_controller.number import ( PID_NUMBER_ENTITIES, - CONTROL_NUMBER_ENTITIES, + # CONTROL_NUMBER_ENTITIES, ) @@ -9,7 +9,8 @@ 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) + assert len(numbers) == len(PID_NUMBER_ENTITIES) @pytest.mark.parametrize("desc", PID_NUMBER_ENTITIES) From 56a001447f3be9a24bda7e8320f31206251e51ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 16:29:22 +0000 Subject: [PATCH 06/57] ci: bump actions/setup-python from 4 to 5 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-versioning.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-versioning.yml b/.github/workflows/release-versioning.yml index ff3b79f..dba3c73 100644 --- a/.github/workflows/release-versioning.yml +++ b/.github/workflows/release-versioning.yml @@ -21,7 +21,7 @@ jobs: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' From d92d1bf1b7a51ed9e44d25fac6d5d8519d03cb46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 16:29:25 +0000 Subject: [PATCH 07/57] ci: bump softprops/action-gh-release from 1 to 2 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '2' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-versioning.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-versioning.yml b/.github/workflows/release-versioning.yml index ff3b79f..4e50987 100644 --- a/.github/workflows/release-versioning.yml +++ b/.github/workflows/release-versioning.yml @@ -50,7 +50,7 @@ jobs: uses: actions/checkout@v3 - name: Create GitHub Release from tag - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.event.release.tag_name }} env: From 3947d900d96d9661de4e8c75243a5d2b86bc7c45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 16:29:28 +0000 Subject: [PATCH 08/57] ci: bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release-versioning.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-versioning.yml b/.github/workflows/release-versioning.yml index ff3b79f..0686da0 100644 --- a/.github/workflows/release-versioning.yml +++ b/.github/workflows/release-versioning.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Create GitHub Release from tag uses: softprops/action-gh-release@v1 From af78ba767622648dbf749f3499f6313c714b3aa7 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 16 May 2025 12:08:13 +0000 Subject: [PATCH 09/57] Updated tests with PID controller: Changes to be committed: modified: tests/conftest.py modified: tests/test_sensor.py --- tests/conftest.py | 31 ++++++++++++++++++++ tests/test_sensor.py | 68 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 51ffc2c..36f7a46 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from homeassistant.helpers.device_registry import DeviceRegistry from custom_components.simple_pid_controller.const import DOMAIN, CONF_SENSOR_ENTITY_ID +import custom_components.simple_pid_controller.sensor as sensor_mod + from homeassistant.const import CONF_NAME @@ -39,3 +41,32 @@ async def config_entry(hass, device_registry: DeviceRegistry): ) return entry + + +class FakePID: + instances = [] + + def __init__(self, kp, ki, kd, setpoint=None): + FakePID.instances.append(self) + self.sample_time = None + self.output_limits = None + self.auto_mode = None + self.proportional_on_measurement = None + self._integral = 0.5 + self._last_output = None + self._outputs = [10.0, 12.0] + + def __call__(self, input_value): + if self._outputs: + out = self._outputs.pop(0) + else: + out = self._last_output or 0.0 + self._last_output = out + return out + + +@pytest.fixture(autouse=True) +def fake_pid(monkeypatch): + FakePID.instances.clear() + monkeypatch.setattr(sensor_mod, "PID", FakePID) + return FakePID diff --git a/tests/test_sensor.py b/tests/test_sensor.py index e79e9e3..d3acb4b 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,5 +1,65 @@ -async def test_sensor_platform(hass, config_entry): - """At least one sensor must be created.""" +import pytest +from datetime import timedelta +from homeassistant.util.dt import utcnow +from pytest_homeassistant_custom_component.common import async_fire_time_changed +from custom_components.simple_pid_controller.number import DOMAIN - sensors = hass.states.async_entity_ids("sensor") - assert len(sensors) >= 1 + +@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.""" + # Stel sample_time in op 5 seconden voor dit voorbeeld + sample_time = 5 + + # Stub de handle zodat hij sample_time teruggeeft + handle = hass.data[DOMAIN][config_entry.entry_id] + handle.get_input_sensor_value = lambda: 10.0 + handle.get_number = lambda key: { + "kp": 1.0, + "ki": 0.1, + "kd": 0.01, + "setpoint": 20.0, + "sample_time": sample_time, + "output_min": 0.0, + "output_max": 100.0, + }[key] + handle.get_switch = lambda key: True + + # 1) trigger eerste update + hass.bus.async_fire("homeassistant_started") + await hass.async_block_till_done() + + # 2) simuleer sample_time seconden later + future = utcnow() + timedelta(seconds=sample_time) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # Nu is de tweede update gelopen; controleer output/contributions + out_entity = f"sensor.{config_entry.entry_id}_pid_output" + state = hass.states.get(out_entity) + assert state is not None + # print("result: " + str(state.state)) + assert float(state.state) == 12.0 + + """ + output_entity = f"sensor.{config_entry.entry_id}_pid_output" + + output_state = hass.states.get(output_entity) + assert output_state is not None, f"PID output sensor {output_entity} not found" + # State should be numeric + try: + float(output_state.state) + except (ValueError, TypeError): + pytest.fail(f"PID output state {output_state.state!r} is not numeric") + + # PID Contribution Sensors (P, I, D) + for comp in ("p", "i", "d"): + contrib_entity = f"sensor.{config_entry.entry_id}_pid_{comp}_contrib" + contrib_state = hass.states.get(contrib_entity) + assert contrib_state is not None, f"PID {comp.upper()} contribution sensor {contrib_entity} not found" + # Contribution should be numeric (even zero) + try: + float(contrib_state.state) + except (ValueError, TypeError): + pytest.fail(f"PID {comp.upper()} contribution state {contrib_state.state!r} is not numeric") + """ From 7ec3032550e6fdc7069f6956daf222ad095b42f1 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 17 May 2025 17:21:27 +0000 Subject: [PATCH 10/57] modified: tests/conftest.py modified: tests/test_sensor.py --- tests/conftest.py | 51 ++++++++++++++++------------------------- tests/test_sensor.py | 54 ++++++++++++++++++++++---------------------- 2 files changed, 46 insertions(+), 59 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 36f7a46..4e1e8ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,40 +33,27 @@ async def config_entry(hass, device_registry: DeviceRegistry): ) entry.add_to_hass(hass) + await hass.async_block_till_done() - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.entry_id, - ) + # device_registry.async_get_or_create( + # config_entry_id=entry.entry_id, + # identifiers={(DOMAIN, entry.entry_id)}, + # name=entry.entry_id, + # ) return entry -class FakePID: - instances = [] - - def __init__(self, kp, ki, kd, setpoint=None): - FakePID.instances.append(self) - self.sample_time = None - self.output_limits = None - self.auto_mode = None - self.proportional_on_measurement = None - self._integral = 0.5 - self._last_output = None - self._outputs = [10.0, 12.0] - - def __call__(self, input_value): - if self._outputs: - out = self._outputs.pop(0) - else: - out = self._last_output or 0.0 - self._last_output = out - return out - - -@pytest.fixture(autouse=True) -def fake_pid(monkeypatch): - FakePID.instances.clear() - monkeypatch.setattr(sensor_mod, "PID", FakePID) - return FakePID +@pytest.fixture +async def sensor_entities(hass, config_entry): + """ + catch all SensorEntity-instances + """ + created = [] + # async_add_entities callback fills 'created' + await sensor_mod.async_setup_entry( + hass, config_entry, lambda entities: created.extend(entities) + ) + # wait till all items are created + await hass.async_block_till_done() + return created diff --git a/tests/test_sensor.py b/tests/test_sensor.py index d3acb4b..93faafa 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -8,10 +8,10 @@ @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.""" - # Stel sample_time in op 5 seconden voor dit voorbeeld + # Set sample time sample_time = 5 - # Stub de handle zodat hij sample_time teruggeeft + # Stub the handle handle = hass.data[DOMAIN][config_entry.entry_id] handle.get_input_sensor_value = lambda: 10.0 handle.get_number = lambda key: { @@ -25,41 +25,41 @@ async def test_pid_output_and_contributions_update(hass, config_entry): }[key] handle.get_switch = lambda key: True - # 1) trigger eerste update + # 1) trigger update hass.bus.async_fire("homeassistant_started") await hass.async_block_till_done() - # 2) simuleer sample_time seconden later + # 2) fake sample_time later in time future = utcnow() + timedelta(seconds=sample_time) async_fire_time_changed(hass, future) await hass.async_block_till_done() - # Nu is de tweede update gelopen; controleer output/contributions + # 2nd update done, check output/contributions out_entity = f"sensor.{config_entry.entry_id}_pid_output" state = hass.states.get(out_entity) assert state is not None - # print("result: " + str(state.state)) - assert float(state.state) == 12.0 + assert float(state.state) != 0 - """ - output_entity = f"sensor.{config_entry.entry_id}_pid_output" - output_state = hass.states.get(output_entity) - assert output_state is not None, f"PID output sensor {output_entity} not found" - # State should be numeric - try: - float(output_state.state) - except (ValueError, TypeError): - pytest.fail(f"PID output state {output_state.state!r} is not numeric") +""" +@pytest.mark.asyncio +async def test_contribution_sensors_native_value(sensor_entities, hass, config_entry): + # Find all PIDContributionSensor-instances + contrib = [ + s for s in sensor_entities + if isinstance(s, PIDContributionSensor) + ] + assert len(contrib) == 3 + + # set to known tuple + handle = hass.data[DOMAIN][config_entry.entry_id] + handle.last_contributions = (1.234, 2.345, 3.456) - # PID Contribution Sensors (P, I, D) - for comp in ("p", "i", "d"): - contrib_entity = f"sensor.{config_entry.entry_id}_pid_{comp}_contrib" - contrib_state = hass.states.get(contrib_entity) - assert contrib_state is not None, f"PID {comp.upper()} contribution sensor {contrib_entity} not found" - # Contribution should be numeric (even zero) - try: - float(contrib_state.state) - except (ValueError, TypeError): - pytest.fail(f"PID {comp.upper()} contribution state {contrib_state.state!r} is not numeric") - """ + # Check native_value() + for sensor_obj, exp in zip( + sorted(contrib_sensors, key=lambda s: s._component), + (0.24, 0.22, 3.46), + ): + print(sensor_obj.native_value) + assert sensor_obj.native_value == exp +""" From 306ee14353d835327fe4c48934c88a220c9b9198 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 17 May 2025 17:27:44 +0000 Subject: [PATCH 11/57] Changed default name to CONF_NAME modified: custom_components/simple_pid_controller/__init__.py modified: custom_components/simple_pid_controller/config_flow.py --- custom_components/simple_pid_controller/__init__.py | 2 +- custom_components/simple_pid_controller/config_flow.py | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index ad53667..b13bd3b 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -72,7 +72,7 @@ def get_input_sensor_value(self) -> float | None: return float(state.state) except ValueError: _LOGGER.warning( - f"Sensor {self.sensor_entity_id} heeft geen geldige waarde. PID-berekening wordt overgeslagen." + f"Sensor {self.sensor_entity_id} has invalid value. PID-calculation skipped." ) return None diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index 349031e..e0e8102 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -51,9 +51,7 @@ async def async_step_user( step_id="user", data_schema=vol.Schema( { - vol.Required( - CONF_NAME, default=self.hass.config.location_name - ): str, + vol.Required(CONF_NAME, default=CONF_NAME): str, vol.Required(CONF_SENSOR_ENTITY_ID): selector( {"entity": {"domain": "sensor"}} ), From ecc0a483bf2de853b5115a1dd89502d23c91ac81 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sat, 17 May 2025 19:32:10 +0200 Subject: [PATCH 12/57] Revert "Changed default name to CONF_NAME" This reverts commit 306ee14353d835327fe4c48934c88a220c9b9198. --- custom_components/simple_pid_controller/__init__.py | 2 +- custom_components/simple_pid_controller/config_flow.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index b13bd3b..ad53667 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -72,7 +72,7 @@ def get_input_sensor_value(self) -> float | None: return float(state.state) except ValueError: _LOGGER.warning( - f"Sensor {self.sensor_entity_id} has invalid value. PID-calculation skipped." + f"Sensor {self.sensor_entity_id} heeft geen geldige waarde. PID-berekening wordt overgeslagen." ) return None diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index e0e8102..349031e 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -51,7 +51,9 @@ async def async_step_user( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_NAME, default=CONF_NAME): str, + vol.Required( + CONF_NAME, default=self.hass.config.location_name + ): str, vol.Required(CONF_SENSOR_ENTITY_ID): selector( {"entity": {"domain": "sensor"}} ), From 62fcb921f8091247099d371d4ea9fae40f54270c Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 18 May 2025 09:41:46 +0200 Subject: [PATCH 13/57] Fix device name --- custom_components/simple_pid_controller/number.py | 3 ++- custom_components/simple_pid_controller/sensor.py | 2 +- custom_components/simple_pid_controller/switch.py | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index a1c7e0b..8e203ab 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -87,7 +87,8 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - name = entry.entry_id + handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] + name = handle.name entities = [PIDParameterNumber(entry, name, desc) for desc in PID_NUMBER_ENTITIES] async_add_entities(entities) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 49546fe..e6b4d99 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( ) -> None: """Set up PID output and diagnostic sensors.""" handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = entry.entry_id + name = handle.name # Init PID with default values pid = PID(1.0, 0.1, 0.05, setpoint=50) diff --git a/custom_components/simple_pid_controller/switch.py b/custom_components/simple_pid_controller/switch.py index e72bcf8..29637ff 100644 --- a/custom_components/simple_pid_controller/switch.py +++ b/custom_components/simple_pid_controller/switch.py @@ -22,7 +22,8 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - name = entry.entry_id + handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] + name = handle.name async_add_entities([PIDOptionSwitch(entry, name, desc) for desc in SWITCH_ENTITIES]) From f526259f1ee34f215b48a4cda3a1e45c364b0e1a Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 18 May 2025 09:46:22 +0200 Subject: [PATCH 14/57] fix default name --- custom_components/simple_pid_controller/config_flow.py | 4 ++-- custom_components/simple_pid_controller/const.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index 349031e..f0a0010 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.core import callback from homeassistant.helpers.selector import selector -from .const import DOMAIN, CONF_NAME, CONF_SENSOR_ENTITY_ID +from .const import DOMAIN, CONF_NAME, DEFAULT_NAME, CONF_SENSOR_ENTITY_ID _LOGGER = logging.getLogger(__name__) @@ -52,7 +52,7 @@ async def async_step_user( data_schema=vol.Schema( { vol.Required( - CONF_NAME, default=self.hass.config.location_name + CONF_NAME, default=DEFAULT_NAME ): str, vol.Required(CONF_SENSOR_ENTITY_ID): selector( {"entity": {"domain": "sensor"}} diff --git a/custom_components/simple_pid_controller/const.py b/custom_components/simple_pid_controller/const.py index 5dec9da..270f983 100644 --- a/custom_components/simple_pid_controller/const.py +++ b/custom_components/simple_pid_controller/const.py @@ -2,4 +2,5 @@ DOMAIN = "simple_pid_controller" CONF_NAME = "name" +DEFAULT_NAME = "sPID-x" CONF_SENSOR_ENTITY_ID = "sensor_entity_id" From 39a0de263a88848d0251be9e514b401087c25748 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 18 May 2025 07:52:37 +0000 Subject: [PATCH 15/57] modified: custom_components/simple_pid_controller/config_flow.py modified: custom_components/simple_pid_controller/number.py modified: custom_components/simple_pid_controller/switch.py --- custom_components/simple_pid_controller/config_flow.py | 4 +--- custom_components/simple_pid_controller/number.py | 1 + custom_components/simple_pid_controller/switch.py | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index f0a0010..5981977 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -51,9 +51,7 @@ async def async_step_user( step_id="user", data_schema=vol.Schema( { - vol.Required( - CONF_NAME, default=DEFAULT_NAME - ): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_SENSOR_ENTITY_ID): selector( {"entity": {"domain": "sensor"}} ), diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 8e203ab..2e26927 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -7,6 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory +from . import PIDDeviceHandle from .const import DOMAIN diff --git a/custom_components/simple_pid_controller/switch.py b/custom_components/simple_pid_controller/switch.py index 29637ff..b879c25 100644 --- a/custom_components/simple_pid_controller/switch.py +++ b/custom_components/simple_pid_controller/switch.py @@ -6,6 +6,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory +from . import PIDDeviceHandle from .const import DOMAIN From 00446e5e7e6c189b3e81ba5c3d91a193593e4a53 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 18 May 2025 12:48:33 +0200 Subject: [PATCH 16/57] fix naming --- custom_components/simple_pid_controller/sensor.py | 2 +- custom_components/simple_pid_controller/switch.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 49546fe..e6b4d99 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( ) -> None: """Set up PID output and diagnostic sensors.""" handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = entry.entry_id + name = handle.name # Init PID with default values pid = PID(1.0, 0.1, 0.05, setpoint=50) diff --git a/custom_components/simple_pid_controller/switch.py b/custom_components/simple_pid_controller/switch.py index 1443221..b879c25 100644 --- a/custom_components/simple_pid_controller/switch.py +++ b/custom_components/simple_pid_controller/switch.py @@ -23,7 +23,8 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - name = entry.entry_id + handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] + name = handle.name async_add_entities([PIDOptionSwitch(entry, name, desc) for desc in SWITCH_ENTITIES]) From 635f907966fcd1febd89ab8b18446381e0e0c34a Mon Sep 17 00:00:00 2001 From: bvweerd Date: Sun, 18 May 2025 19:09:20 +0200 Subject: [PATCH 17/57] add min/max values --- .../simple_pid_controller/number.py | 34 +++++++++++++++++-- .../simple_pid_controller/sensor.py | 8 +++-- .../simple_pid_controller/switch.py | 11 +++++- 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 33007a7..880409e 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -17,6 +17,9 @@ DEFAULT_RANGE_MAX, ) +_LOGGER = logging.getLogger(__name__) + + PID_NUMBER_ENTITIES = [ { "name": "Kp", @@ -159,10 +162,19 @@ def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: self._attr_native_min_value = self._range_min self._attr_native_max_value = self._range_max self._attr_native_step = desc["step"] + if desc['key'] == "setpoint": # a + (b - a) * f: self._attr_native_value = self._range_min + ( self._range_max + self._range_min ) * float(desc["default"]) + elif desc['key'] == "output_min": + self._attr_native_value = self._range_min + elif desc['key'] == "output_max": + self._attr_native_value = self._range_max + else: + #error + _LOGGER.debug("Unreachable state 1 in number.py is reached. Please report.") + self._attr_entity_category = desc["entity_category"] # Device-info @@ -182,12 +194,30 @@ def native_value(self) -> float: @property def min_value(self) -> float: - return self._range_min + if desc['key'] == "setpoint": + return self._range_min + elif desc['key'] == "output_min": + return abs(self._range_max) * -1 + elif desc['key'] == "output_max": + return 0.0 + else: + # error + _LOGGER.debug("Unreachable state 2 in number.py is reached. Please report.") + @property def max_value(self) -> float: - return self._range_max + if desc['key'] == "setpoint": + return self._range_max + elif desc['key'] == "output_min": + return 0.0 + elif desc['key'] == "output_max": + return self._range_max + else: + # error + _LOGGER.debug("Unreachable state 3 in number.py is reached. Please report.") async def async_set_native_value(self, value: float) -> None: self._attr_native_value = value self.async_write_ha_state() + diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index e6b4d99..999586d 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -52,12 +52,16 @@ async def update_pid(): out_max = handle.get_number("output_max") auto_mode = handle.get_switch("auto_mode") p_on_m = handle.get_switch("proportional_on_measurement") + windup_protection = handle.get_switch("windup_protection") # Pas live de PID-instellingen aan pid.tunings = (kp, ki, kd) pid.setpoint = setpoint pid.sample_time = sample_time - pid.output_limits = (out_min, out_max) + if windup_protection: + pid.output_limits = (out_min, out_max) + else: + pid.output_limits = (None, None) pid.auto_mode = auto_mode pid.proportional_on_measurement = p_on_m @@ -132,7 +136,7 @@ def _listener(event): "state_changed", make_listener(f"number.{entry.entry_id}_{key}") ) - for key in ["auto_mode", "proportional_on_measurement"]: + for key in ["auto_mode", "proportional_on_measurement", "windup_protection"]: hass.bus.async_listen( "state_changed", make_listener(f"switch.{entry.entry_id}_{key}") ) diff --git a/custom_components/simple_pid_controller/switch.py b/custom_components/simple_pid_controller/switch.py index b879c25..a0087a8 100644 --- a/custom_components/simple_pid_controller/switch.py +++ b/custom_components/simple_pid_controller/switch.py @@ -11,12 +11,21 @@ from .const import DOMAIN SWITCH_ENTITIES = [ - {"key": "auto_mode", "name": "Auto Mode", "default_state": True}, + { + "key": "auto_mode", + "name": "Auto Mode", + "default_state": True + }, { "key": "proportional_on_measurement", "name": "Proportional on Measurement", "default_state": False, }, + { + "key": "windup_protection", + "name": "Windup Protection", + "default_state": True, + }, ] From 35d4ac10c29b315a146a9d6ff46fae5daed758f4 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 06:10:13 +0000 Subject: [PATCH 18/57] modified: custom_components/simple_pid_controller/number.py --- .../simple_pid_controller/number.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 880409e..205e770 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.components.number import RestoreNumber from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -94,11 +96,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] name = handle.name - entities = [PIDParameterNumber(entry, name, desc) for desc in PID_NUMBER_ENTITIES] async_add_entities(entities) @@ -162,19 +162,21 @@ def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: self._attr_native_min_value = self._range_min self._attr_native_max_value = self._range_max self._attr_native_step = desc["step"] - if desc['key'] == "setpoint": - # a + (b - a) * f: - self._attr_native_value = self._range_min + ( - self._range_max + self._range_min - ) * float(desc["default"]) - elif desc['key'] == "output_min": + self.key = desc["key"] + + if self.key == "setpoint": + # a + (b - a) * f: + self._attr_native_value = self._range_min + ( + self._range_max + self._range_min + ) * float(desc["default"]) + elif self.key == "output_min": self._attr_native_value = self._range_min - elif desc['key'] == "output_max": + elif self.key == "output_max": self._attr_native_value = self._range_max else: - #error + # error _LOGGER.debug("Unreachable state 1 in number.py is reached. Please report.") - + self._attr_entity_category = desc["entity_category"] # Device-info @@ -194,24 +196,23 @@ def native_value(self) -> float: @property def min_value(self) -> float: - if desc['key'] == "setpoint": + if self.key == "setpoint": return self._range_min - elif desc['key'] == "output_min": + elif self.key == "output_min": return abs(self._range_max) * -1 - elif desc['key'] == "output_max": + elif self.key == "output_max": return 0.0 else: # error _LOGGER.debug("Unreachable state 2 in number.py is reached. Please report.") - @property def max_value(self) -> float: - if desc['key'] == "setpoint": + if self.key == "setpoint": return self._range_max - elif desc['key'] == "output_min": + elif self.key == "output_min": return 0.0 - elif desc['key'] == "output_max": + elif self.key == "output_max": return self._range_max else: # error @@ -220,4 +221,3 @@ def max_value(self) -> float: async def async_set_native_value(self, value: float) -> None: self._attr_native_value = value self.async_write_ha_state() - From 0a38ddc93c6ded49e764e8c97222ad743732f713 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 07:28:26 +0000 Subject: [PATCH 19/57] modified: custom_components/simple_pid_controller/config_flow.py modified: custom_components/simple_pid_controller/number.py --- .../simple_pid_controller/config_flow.py | 33 ++++--- .../simple_pid_controller/number.py | 93 +++++++++---------- 2 files changed, 61 insertions(+), 65 deletions(-) diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index 7f3c160..69c0203 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -41,7 +41,7 @@ def async_get_options_flow( config_entry: ConfigEntry, ) -> PIDControllerOptionsFlowHandler: """Get the options flow for this handler.""" - return PIDControllerOptionsFlowHandler(config_entry) + return PIDControllerOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -78,34 +78,39 @@ async def async_step_user( class PIDControllerOptionsFlowHandler(OptionsFlow): """Handle options for PID Controller.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry + super().__init__() async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Manage the options.""" + """Manage the options form and save user input.""" + # If the user has submitted the form, create the entry if user_input is not None: - return self.async_create_entry(title="", data=user_input) + return self.async_create_entry( + title=self.config_entry.title, + data=user_input, + ) - current_sensor = self.config_entry.options.get( - CONF_SENSOR_ENTITY_ID, - self.config_entry.data.get(CONF_SENSOR_ENTITY_ID, ""), - ) + # Pre‐fill form with existing options or sensible defaults + current_sensor = self.config_entry.options.get(CONF_SENSOR_ENTITY_ID) + current_min = self.config_entry.options.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN) + current_max = self.config_entry.options.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX) options_schema = vol.Schema( { - vol.Required(CONF_SENSOR_ENTITY_ID, default=current_sensor): selector( - {"entity": {"domain": "sensor"}} - ), + vol.Required( + CONF_SENSOR_ENTITY_ID, + default=current_sensor, + ): selector({"entity": {"domain": "sensor"}}), vol.Required( CONF_RANGE_MIN, - default=current_sensor.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN), + default=current_min, ): vol.Coerce(float), vol.Required( CONF_RANGE_MAX, - default=current_sensor.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX), + default=current_max, ): vol.Coerce(float), } ) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 205e770..11a8d3a 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -143,43 +143,58 @@ async def async_set_native_value(self, value: float) -> None: class ControlParameterNumber(RestoreNumber): - def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: - opts = entry.options or {} - data = entry.data or {} - self._range_min = opts.get( - CONF_RANGE_MIN, data.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN) - ) - self._range_max = opts.get( - CONF_RANGE_MAX, data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX) - ) + """Number entity for PID control parameters.""" + def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: self._attr_name = f"{desc['name']}" self._attr_has_entity_name = True self._attr_unique_id = f"{entry.entry_id}_{desc['key']}" self._attr_icon = "mdi:ray-vertex" self._attr_mode = "box" self._attr_native_unit_of_measurement = desc["unit"] - self._attr_native_min_value = self._range_min - self._attr_native_max_value = self._range_max self._attr_native_step = desc["step"] - self.key = desc["key"] - - if self.key == "setpoint": - # a + (b - a) * f: - self._attr_native_value = self._range_min + ( - self._range_max + self._range_min - ) * float(desc["default"]) - elif self.key == "output_min": - self._attr_native_value = self._range_min - elif self.key == "output_max": - self._attr_native_value = self._range_max - else: - # error - _LOGGER.debug("Unreachable state 1 in number.py is reached. Please report.") - + self._attr_native_value = desc["default"] self._attr_entity_category = desc["entity_category"] + self._key = desc["key"] + + # Compute range limits based on key + opts = entry.options or {} + data = entry.data or {} + range_min = opts.get( + CONF_RANGE_MIN, data.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN) + ) + range_max = opts.get( + CONF_RANGE_MAX, data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX) + ) + + if self._key == "setpoint": + min_val, max_val = range_min, range_max + elif self._key == "output_min": + min_val, max_val = -abs(range_max), 0.0 + elif self._key == "output_max": + min_val, max_val = 0.0, range_max + else: + _LOGGER.error("Unexpected PID parameter key: %s", self._key) + min_val, max_val = DEFAULT_RANGE_MIN, DEFAULT_RANGE_MAX + + 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 + self._attr_native_value = self._key + + if self._key == "setpoint": + self._attr_native_value = range_min + (range_max + range_min) * float( + desc["default"] + ) + elif self._key == "output_min": + self._attr_native_value = range_min + elif self._key == "output_max": + self._attr_native_value = range_max + else: + _LOGGER.error("Unexpected error, unknown state in number.py") - # Device-info self._attr_device_info = { "identifiers": {(DOMAIN, entry.entry_id)}, "name": device_name, @@ -194,30 +209,6 @@ async def async_added_to_hass(self) -> None: def native_value(self) -> float: return self._attr_native_value - @property - def min_value(self) -> float: - if self.key == "setpoint": - return self._range_min - elif self.key == "output_min": - return abs(self._range_max) * -1 - elif self.key == "output_max": - return 0.0 - else: - # error - _LOGGER.debug("Unreachable state 2 in number.py is reached. Please report.") - - @property - def max_value(self) -> float: - if self.key == "setpoint": - return self._range_max - elif self.key == "output_min": - return 0.0 - elif self.key == "output_max": - return self._range_max - else: - # error - _LOGGER.debug("Unreachable state 3 in number.py is reached. Please report.") - async def async_set_native_value(self, value: float) -> None: self._attr_native_value = value self.async_write_ha_state() From d5d7da4151a071d1355c81b8ea0512059bfed2cb Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 08:19:08 +0000 Subject: [PATCH 20/57] modified: custom_components/simple_pid_controller/__init__.py modified: tests/test_config_flow.py --- .../simple_pid_controller/__init__.py | 10 +++++++ tests/test_config_flow.py | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index ce8dcf3..e8a2549 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -105,6 +105,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: handle = PIDDeviceHandle(hass, entry) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = handle + # register updatelistener for optionsflow + entry.async_on_unload(entry.add_update_listener(_async_update_options_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -114,3 +117,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN]: hass.data[DOMAIN].pop(entry.entry_id) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _async_update_options_listener( + hass: HomeAssistant, entry: ConfigEntry +) -> None: + """Update after options are changed in optionsflow""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0b65fe8..e767838 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,10 +1,14 @@ import pytest from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResultType + from custom_components.simple_pid_controller.const import ( DOMAIN, CONF_SENSOR_ENTITY_ID, CONF_NAME, + CONF_RANGE_MIN, + CONF_RANGE_MAX, ) @@ -35,3 +39,28 @@ async def test_create_entry(hass): assert result2["type"] == "create_entry" assert result2["title"] == "My PID" assert result2["data"][CONF_SENSOR_ENTITY_ID] == "sensor.test" + + +async def test_options_flow(hass, config_entry): + """After submitting the OptionsFlow, entry.options is updated.""" + # 1) Start de options flow via de dedicated helper + init_result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert init_result["type"] == FlowResultType.FORM + assert init_result["step_id"] == "init" + + # 2) Dien nieuwe opties in + new_options = { + CONF_SENSOR_ENTITY_ID: "sensor.new", + CONF_RANGE_MIN: 1.0, + CONF_RANGE_MAX: 10.0, + } + finish_result = await hass.config_entries.options.async_configure( + init_result["flow_id"], + user_input=new_options, + ) + + # 3) Verifieer dat de flow CREATE_ENTRY teruggeeft en options zijn bijgewerkt + assert finish_result["type"] == FlowResultType.CREATE_ENTRY + assert config_entry.options[CONF_SENSOR_ENTITY_ID] == "sensor.new" + assert config_entry.options[CONF_RANGE_MIN] == 1.0 + assert config_entry.options[CONF_RANGE_MAX] == 10.0 From db543c700c83aceaa6d5065807e28a6413bb50a1 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 08:52:02 +0000 Subject: [PATCH 21/57] modified: README.md modified: custom_components/simple_pid_controller/number.py --- README.md | 5 +++++ custom_components/simple_pid_controller/number.py | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d5e24f6..6a14eec 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ | Number | `Sample Time` | PID evaluation interval | | Switch | `Auto Mode` | Toggle automatic control | | Switch | `Proportional on Measurement` | Change proportional mode | +| Switch | `Windup Protection` | Toggle windup protection | + > 💡 All entities are editable via the UI in **Settings > Devices & Services > [Your Controller] > Options**. @@ -87,6 +89,9 @@ - **Sensor Entity**: e.g., `sensor.living_room_temperature` 4. Submit and finish setup +## Default Range +The controller’s setpoint range defaults to 0.0-100.0. To customize this range, go to Settings > Devices & Services, select the Simple PID Controller integration, click Options, adjust the Range Min and Range Max values to your desired bounds, and save. The new range will apply immediately. + --- ## 📊 Entities Overview diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 11a8d3a..5966798 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -131,7 +131,12 @@ def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if (last := await self.async_get_last_number_data()) is not None: - self._attr_native_value = last.native_value + if last.native_value < self._attr_native_min_value: + self._attr_native_value = self._attr_native_min_value + elif last.native_value > self._attr_native_max_value: + self._attr_native_value = self._attr_native_max_value + else: + self._attr_native_value = last.native_value @property def native_value(self) -> float: @@ -182,8 +187,6 @@ def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: self._attr_native_step = desc.get("step", 1.0) # Initialize current value - self._attr_native_value = self._key - if self._key == "setpoint": self._attr_native_value = range_min + (range_max + range_min) * float( desc["default"] From 8fd41759ae47fcbf0f2900d6eb34f573c75428ca Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 09:37:13 +0000 Subject: [PATCH 22/57] Changes to be committed: modified: custom_components/simple_pid_controller/number.py --- custom_components/simple_pid_controller/number.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 5966798..fb16688 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -206,7 +206,12 @@ def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if (last := await self.async_get_last_number_data()) is not None: - self._attr_native_value = last.native_value + if last.native_value < self._attr_native_min_value: + self._attr_native_value = self._attr_native_min_value + elif last.native_value > self._attr_native_max_value: + self._attr_native_value = self._attr_native_max_value + else: + self._attr_native_value = last.native_value @property def native_value(self) -> float: From be657c3b09e05bffe99240d16c3520d25aed6797 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 09:51:03 +0000 Subject: [PATCH 23/57] modified: custom_components/simple_pid_controller/config_flow.py --- custom_components/simple_pid_controller/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index 69c0203..3b07806 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -93,8 +93,10 @@ async def async_step_init( data=user_input, ) - # Pre‐fill form with existing options or sensible defaults - current_sensor = self.config_entry.options.get(CONF_SENSOR_ENTITY_ID) + # Pre-fill form with existing options or sensible defaults + current_sensor = self.config_entry.options.get( + CONF_SENSOR_ENTITY_ID + ) or self.config_entry.data.get(CONF_SENSOR_ENTITY_ID) current_min = self.config_entry.options.get(CONF_RANGE_MIN, DEFAULT_RANGE_MIN) current_max = self.config_entry.options.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX) From 87da546baaef0b55e540bb6d2dc2fbaf70a486b0 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 10:57:52 +0000 Subject: [PATCH 24/57] Changes to be committed: modified: custom_components/simple_pid_controller/sensor.py modified: custom_components/simple_pid_controller/switch.py --- custom_components/simple_pid_controller/sensor.py | 2 +- custom_components/simple_pid_controller/switch.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 999586d..0e68bc2 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -61,7 +61,7 @@ async def update_pid(): if windup_protection: pid.output_limits = (out_min, out_max) else: - pid.output_limits = (None, None) + pid.output_limits = (None, None) pid.auto_mode = auto_mode pid.proportional_on_measurement = p_on_m diff --git a/custom_components/simple_pid_controller/switch.py b/custom_components/simple_pid_controller/switch.py index a0087a8..7265243 100644 --- a/custom_components/simple_pid_controller/switch.py +++ b/custom_components/simple_pid_controller/switch.py @@ -11,11 +11,7 @@ from .const import DOMAIN SWITCH_ENTITIES = [ - { - "key": "auto_mode", - "name": "Auto Mode", - "default_state": True - }, + {"key": "auto_mode", "name": "Auto Mode", "default_state": True}, { "key": "proportional_on_measurement", "name": "Proportional on Measurement", From 0c36e79d704085d44f052847cd83b0bb475874a1 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 12:18:53 +0000 Subject: [PATCH 25/57] new file: custom_components/simple_pid_controller/entity.py modified: custom_components/simple_pid_controller/number.py modified: custom_components/simple_pid_controller/sensor.py modified: custom_components/simple_pid_controller/switch.py --- .../simple_pid_controller/entity.py | 34 +++++++++++ .../simple_pid_controller/number.py | 37 ++++-------- .../simple_pid_controller/sensor.py | 60 +++++++++---------- .../simple_pid_controller/switch.py | 21 ++----- 4 files changed, 79 insertions(+), 73 deletions(-) create mode 100644 custom_components/simple_pid_controller/entity.py diff --git a/custom_components/simple_pid_controller/entity.py b/custom_components/simple_pid_controller/entity.py new file mode 100644 index 0000000..8b5835b --- /dev/null +++ b/custom_components/simple_pid_controller/entity.py @@ -0,0 +1,34 @@ +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, + ) diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index fb16688..10f3f40 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -9,10 +9,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory -from . import PIDDeviceHandle +from .entity import BasePIDEntity from .const import ( - DOMAIN, CONF_RANGE_MIN, CONF_RANGE_MAX, DEFAULT_RANGE_MIN, @@ -96,23 +95,20 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name - - entities = [PIDParameterNumber(entry, name, desc) for desc in PID_NUMBER_ENTITIES] + entities = [PIDParameterNumber(hass, entry, desc) for desc in PID_NUMBER_ENTITIES] async_add_entities(entities) entities = [ - ControlParameterNumber(entry, name, desc) for desc in CONTROL_NUMBER_ENTITIES + ControlParameterNumber(hass, entry, desc) for desc in CONTROL_NUMBER_ENTITIES ] async_add_entities(entities) class PIDParameterNumber(RestoreNumber): - def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: - self._attr_name = f"{desc['name']}" - self._attr_has_entity_name = True - self._attr_unique_id = f"{entry.entry_id}_{desc['key']}" + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None: + BasePIDEntity.__init__(self, hass, entry, desc["key"], desc["name"]) + RestoreNumber.__init__(self) + self._attr_icon = "mdi:ray-vertex" self._attr_mode = "box" self._attr_native_unit_of_measurement = desc["unit"] @@ -122,12 +118,6 @@ def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: self._attr_native_value = desc["default"] self._attr_entity_category = desc["entity_category"] - # Device-info - self._attr_device_info = { - "identifiers": {(DOMAIN, entry.entry_id)}, - "name": device_name, - } - async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if (last := await self.async_get_last_number_data()) is not None: @@ -150,10 +140,10 @@ async def async_set_native_value(self, value: float) -> None: class ControlParameterNumber(RestoreNumber): """Number entity for PID control parameters.""" - def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: - self._attr_name = f"{desc['name']}" - self._attr_has_entity_name = True - self._attr_unique_id = f"{entry.entry_id}_{desc['key']}" + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None: + BasePIDEntity.__init__(self, hass, entry, desc["key"], desc["name"]) + RestoreNumber.__init__(self) + self._attr_icon = "mdi:ray-vertex" self._attr_mode = "box" self._attr_native_unit_of_measurement = desc["unit"] @@ -198,11 +188,6 @@ def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: else: _LOGGER.error("Unexpected error, unknown state in number.py") - self._attr_device_info = { - "identifiers": {(DOMAIN, entry.entry_id)}, - "name": device_name, - } - async def async_added_to_hass(self) -> None: await super().async_added_to_hass() if (last := await self.async_get_last_number_data()) is not None: diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 0e68bc2..c66e478 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -16,6 +16,7 @@ from typing import Any from . import PIDDeviceHandle +from .entity import BasePIDEntity from .const import DOMAIN from .coordinator import PIDDataCoordinator @@ -28,8 +29,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up PID output and diagnostic sensors.""" - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name # Init PID with default values pid = PID(1.0, 0.1, 0.05, setpoint=50) @@ -96,6 +95,9 @@ async def update_pid(): return output # Setup Coordinator + handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] + name = handle.name + coordinator = PIDDataCoordinator(hass, name, update_pid, interval=10) # Wait for HA to finish starting @@ -107,10 +109,16 @@ async def start_refresh(_: Any) -> None: async_add_entities( [ - PIDOutputSensor(entry, name, coordinator), - PIDContributionSensor(entry, name, "p", handle, coordinator), - PIDContributionSensor(entry, name, "i", handle, coordinator), - PIDContributionSensor(entry, name, "d", handle, coordinator), + PIDOutputSensor(hass, entry, coordinator), + PIDContributionSensor( + hass, entry, "pid_p_contrib", "P contribution", handle, coordinator + ), + PIDContributionSensor( + hass, entry, "pid_i_contrib", "I contribution", handle, coordinator + ), + PIDContributionSensor( + hass, entry, "pid_d_contrib", "D contribution", handle, coordinator + ), ] ) @@ -146,19 +154,16 @@ class PIDOutputSensor(CoordinatorEntity[PIDDataCoordinator], SensorEntity): """Sensor representing the PID output.""" def __init__( - self, entry: ConfigEntry, device_name: str, coordinator: PIDDataCoordinator + self, hass: HomeAssistant, entry: ConfigEntry, coordinator: PIDDataCoordinator ): super().__init__(coordinator) - self._attr_unique_id = f"{entry.entry_id}_pid_output" - self._attr_name = "PID Output" - self._attr_has_entity_name = True - self._attr_native_unit_of_measurement = "%" - # Device-info - self._attr_device_info = { - "identifiers": {(DOMAIN, entry.entry_id)}, - "name": device_name, - } + name = "PID Output" + key = "pid_output" + + BasePIDEntity.__init__(self, hass, entry, key, name) + + self._attr_native_unit_of_measurement = "%" @property def native_value(self) -> float | None: @@ -172,27 +177,20 @@ class PIDContributionSensor(CoordinatorEntity[PIDDataCoordinator], SensorEntity) def __init__( self, + hass: HomeAssistant, entry: ConfigEntry, - device_name: str, - component: str, + key: str, + name: str, handle: PIDDeviceHandle, coordinator: PIDDataCoordinator, ): super().__init__(coordinator) - self._attr_unique_id = f"{entry.entry_id}_pid_{component}_contrib" - self._attr_name = f"PID {component.upper()} Contribution" + + BasePIDEntity.__init__(self, hass, entry, key, name) + self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_entity_registry_enabled_default = False - self._attr_has_entity_name = True - self._handle = handle - self._component = component - self._entry_id = entry.entry_id - - # Device-info - self._attr_device_info = { - "identifiers": {(DOMAIN, entry.entry_id)}, - "name": device_name, - } + self._key = key @property def native_value(self): @@ -201,5 +199,5 @@ def native_value(self): "p": contributions[0], "i": contributions[1], "d": contributions[2], - }.get(self._component) + }.get(self._key) return round(value, 2) if value is not None else None diff --git a/custom_components/simple_pid_controller/switch.py b/custom_components/simple_pid_controller/switch.py index 7265243..6689e39 100644 --- a/custom_components/simple_pid_controller/switch.py +++ b/custom_components/simple_pid_controller/switch.py @@ -6,9 +6,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity import EntityCategory -from . import PIDDeviceHandle -from .const import DOMAIN +from .entity import BasePIDEntity SWITCH_ENTITIES = [ {"key": "auto_mode", "name": "Auto Mode", "default_state": True}, @@ -28,26 +27,16 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name - async_add_entities([PIDOptionSwitch(entry, name, desc) for desc in SWITCH_ENTITIES]) + async_add_entities([PIDOptionSwitch(hass, entry, desc) for desc in SWITCH_ENTITIES]) class PIDOptionSwitch(SwitchEntity, RestoreEntity): - def __init__(self, entry: ConfigEntry, device_name: str, desc: dict) -> None: - self._entry = entry - self._attr_name = f"{desc['name']}" - self._attr_has_entity_name = True - self._attr_unique_id = f"{entry.entry_id}_{desc['key']}" + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None: + BasePIDEntity.__init__(self, hass, entry, desc["key"], desc["name"]) + self._attr_entity_category = EntityCategory.CONFIG self._state = desc["default_state"] - # Device-info - self._attr_device_info = { - "identifiers": {(DOMAIN, entry.entry_id)}, - "name": device_name, - } - async def async_added_to_hass(self) -> None: """Restore previous state if available.""" await super().async_added_to_hass() From 4acc54e59b45140784b9e1503408e50b0cdbfecf Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 13:26:59 +0000 Subject: [PATCH 26/57] Changes to be committed: modified: README.md modified: custom_components/simple_pid_controller/config_flow.py modified: custom_components/simple_pid_controller/number.py modified: custom_components/simple_pid_controller/quality_scale.yaml modified: custom_components/simple_pid_controller/sensor.py modified: custom_components/simple_pid_controller/switch.py modified: tests/test_sensor.py --- README.md | 33 ++++++------ .../simple_pid_controller/config_flow.py | 2 + .../simple_pid_controller/number.py | 3 ++ .../simple_pid_controller/quality_scale.yaml | 50 ++++++++++++------- .../simple_pid_controller/sensor.py | 8 +-- .../simple_pid_controller/switch.py | 3 ++ tests/test_sensor.py | 2 +- 7 files changed, 64 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 6a14eec..132687b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Simple PID Controller -> A Home Assistant integration for real-time PID control with UI-based tuning and diagnostics. +> The Simple PID Controller is a Home Assistant integration for real-time PID control with UI-based tuning and diagnostics. --- @@ -9,6 +9,7 @@ - [Installation](#installation) - [HACS (Recommended)](#hacs-recommended) - [Manual Installation](#manual-installation) + - [Removal Instructions](#removal-instructions) - [Configuration](#configuration) - [Entities Overview](#entities-overview) - [PID Tuning Guide](#pid-tuning-guide) @@ -16,6 +17,8 @@ - [Ziegler–Nichols Method](#2-zieglernichols-method) - [Example PID Graph](#example-pid-graph) - [Support & Development](#support--development) +- [Service Actions](#service-actions) + --- @@ -65,22 +68,15 @@ 1. Download or clone this repository 2. Copy `simple_pid_controller` to `/config/custom_components/` -3. Ensure this folder structure: - ```text - config/ - └── custom_components/ - └── simple_pid_controller/ - ├── __init__.py - ├── manifest.json - ├── sensor.py - ├── number.py - └── switch.py - ``` -4. Restart Home Assistant +3. Restart Home Assistant + +### Removal Instructions +To remove the Simple PID Controller, navigate to **Settings > Devices & Services**, select **Simple PID Controller**, and click **Delete**. If installed manually, delete the `custom_components/simple_pid_controller` directory and restart Home Assistant. --- ## ⚙️ Configuration +The controller is configured through the UI using the Config Flow {% term config_flow %}. 1. Go to **Settings > Devices & Services** 2. Click **Add Integration** and choose **Simple PID Controller** @@ -89,8 +85,8 @@ - **Sensor Entity**: e.g., `sensor.living_room_temperature` 4. Submit and finish setup -## Default Range -The controller’s setpoint range defaults to 0.0-100.0. To customize this range, go to Settings > Devices & Services, select the Simple PID Controller integration, click Options, adjust the Range Min and Range Max values to your desired bounds, and save. The new range will apply immediately. +**Default Range:** +The controller’s setpoint range defaults to **0.0 – 100.0**. To customize this range, select the integration in **Settings > Devices & Services**, click **Options**, adjust **Range Min** and **Range Max**, and save. --- @@ -106,6 +102,7 @@ The controller’s setpoint range defaults to 0.0-100.0. To customize this range | Number | `Sample Time` | PID evaluation rate in seconds. | | Switch | `Auto Mode` | Enable/disable PID automation. | | Switch | `Proportional on Measurement` | Use measurement instead of error for P term. | +| Switch | `Windup Protection` | Toggle windup protection | --- @@ -158,3 +155,9 @@ Here's an example output showing the controller responding to a setpoint: - **GitHub Repository**: [https://github.com/bvweerd/simple_pid_controller](https://github.com/bvweerd/simple_pid_controller) - **Issues & Bugs**: [Report here](https://github.com/bvweerd/simple_pid_controller/issues) +--- + +## 🔧 Service Actions +This Integration does **not** expose any custom services. All interactions are performed via UI-based entities. + + diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index 3b07806..269b1ce 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -48,6 +48,8 @@ async def async_step_user( ) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: + self._async_abort_entries_match({CONF_NAME: user_input[CONF_NAME]}) + return self.async_create_entry( title=user_input[CONF_NAME], data={ diff --git a/custom_components/simple_pid_controller/number.py b/custom_components/simple_pid_controller/number.py index 10f3f40..8c1c59f 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -18,6 +18,9 @@ DEFAULT_RANGE_MAX, ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) diff --git a/custom_components/simple_pid_controller/quality_scale.yaml b/custom_components/simple_pid_controller/quality_scale.yaml index 201a916..00266dc 100644 --- a/custom_components/simple_pid_controller/quality_scale.yaml +++ b/custom_components/simple_pid_controller/quality_scale.yaml @@ -1,19 +1,23 @@ rules: # Bronze - action-setup: todo - appropriate-polling: todo - brands: todo - common-modules: todo + action-setup: + status: exempt + comment: This integration does not provide any actions + appropriate-polling: + status: exempt + comment: This integration does not use polling + brands: done + common-modules: done config-flow-test-coverage: todo config-flow: todo - dependency-transparency: todo - docs-actions: todo - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: todo - entity-unique-id: todo - has-entity-name: todo + entity-unique-id: done + has-entity-name: done runtime-data: todo test-before-configure: todo test-before-setup: todo @@ -25,17 +29,23 @@ rules: docs-configuration-parameters: todo docs-installation-parameters: todo entity-unavailable: todo - integration-owner: todo + integration-owner: done log-when-unavailable: todo parallel-updates: todo - reauthentication-flow: todo + reauthentication-flow: + status: exempt + comment: This integration does not use authentication test-coverage: todo # Gold - devices: todo + devices: done diagnostics: todo discovery-update-info: todo - discovery: todo + status: exempt + comment: This integration does not use discoverable devices + discovery: + status: exempt + comment: This integration does not use discoverable devices docs-data-update: todo docs-examples: todo docs-known-limitations: todo @@ -44,17 +54,21 @@ rules: docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo - entity-category: todo + entity-category: done entity-device-class: todo entity-disabled-by-default: todo entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: + status: exempt + comment: This integration does not use external devices repair-issues: todo stale-devices: todo # Platinum async-dependency: todo - inject-websession: todo + inject-websession: + status: exempt + comment: This integration does not use external devices strict-typing: todo diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index c66e478..364383d 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -20,6 +20,9 @@ from .const import DOMAIN from .coordinator import PIDDataCoordinator +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + _LOGGER = logging.getLogger(__name__) @@ -29,6 +32,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up PID output and diagnostic sensors.""" + handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] + name = handle.name # Init PID with default values pid = PID(1.0, 0.1, 0.05, setpoint=50) @@ -95,9 +100,6 @@ async def update_pid(): return output # Setup Coordinator - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] - name = handle.name - coordinator = PIDDataCoordinator(hass, name, update_pid, interval=10) # Wait for HA to finish starting diff --git a/custom_components/simple_pid_controller/switch.py b/custom_components/simple_pid_controller/switch.py index 6689e39..b5d97d3 100644 --- a/custom_components/simple_pid_controller/switch.py +++ b/custom_components/simple_pid_controller/switch.py @@ -9,6 +9,9 @@ from .entity import BasePIDEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + SWITCH_ENTITIES = [ {"key": "auto_mode", "name": "Auto Mode", "default_state": True}, { diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 93faafa..6c8c7ad 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta from homeassistant.util.dt import utcnow from pytest_homeassistant_custom_component.common import async_fire_time_changed -from custom_components.simple_pid_controller.number import DOMAIN +from custom_components.simple_pid_controller.const import DOMAIN @pytest.mark.asyncio From 495db250825e8fbc6be4a0580588a117cafdb40f Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 13:44:37 +0000 Subject: [PATCH 27/57] modified: custom_components/simple_pid_controller/quality_scale.yaml --- .../simple_pid_controller/quality_scale.yaml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/custom_components/simple_pid_controller/quality_scale.yaml b/custom_components/simple_pid_controller/quality_scale.yaml index 00266dc..4926b79 100644 --- a/custom_components/simple_pid_controller/quality_scale.yaml +++ b/custom_components/simple_pid_controller/quality_scale.yaml @@ -19,9 +19,13 @@ rules: entity-unique-id: done has-entity-name: done runtime-data: todo - test-before-configure: todo - test-before-setup: todo - unique-config-entry: todo + test-before-configure: + status: exempt + comment: This integration has no external dependenc + test-before-setup: + status: exempt + comment: This integration has no external dependencies + unique-config-entry: done # Silver action-exceptions: todo @@ -31,7 +35,7 @@ rules: entity-unavailable: todo integration-owner: done log-when-unavailable: todo - parallel-updates: todo + parallel-updates: done reauthentication-flow: status: exempt comment: This integration does not use authentication From 1fb873c1089098d70288234d169da5c625146f0d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 20:05:52 +0200 Subject: [PATCH 28/57] Create en.json --- .../translations/en.json | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 custom_components/simple_pid_controller/translations/en.json diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json new file mode 100644 index 0000000..1f5b5fa --- /dev/null +++ b/custom_components/simple_pid_controller/translations/en.json @@ -0,0 +1,80 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure PID Controller" + } + }, + "error": { + "already_configured": "A configuration with this name already exists." + }, + "field": { + "name": "Name", + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" + } + }, + "options": { + "step": { + "init": { + "title": "Configure PID Controller Options" + } + }, + "field": { + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" + } + }, + "entity": { + "number": { + "simple_pid_controller": { + "kp": { + "name": "Kp", + "description": "Proportional gain" + }, + "ki": { + "name": "Ki", + "description": "Integral gain" + }, + "kd": { + "name": "Kd", + "description": "Derivative gain" + }, + "setpoint": { + "name": "Setpoint", + "description": "Desired value for the controller" + }, + "output": { + "name": "Output", + "description": "Controller output value" + } + } + }, + "switch": { + "simple_pid_controller": { + "auto_mode": { + "name": "Auto Mode", + "description": "Enable automatic PID mode" + }, + "proportional_on_measurement": { + "name": "Proportional on Measurement", + "description": "Use proportional-on-measurement mode" + }, + "windup_protection": { + "name": "Windup Protection", + "description": "Enable windup protection" + } + } + }, + "sensor": { + "simple_pid_controller": { + "current_value": { + "name": "Current Value", + "description": "Current value measured by the sensor" + } + } + } + } +} \ No newline at end of file From 9b7f367ee253644269cc99487a3ce5af86433eb9 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 20:05:52 +0200 Subject: [PATCH 29/57] Create en.json --- .../translations/en.json | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 custom_components/simple_pid_controller/translations/en.json diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json new file mode 100644 index 0000000..1f5b5fa --- /dev/null +++ b/custom_components/simple_pid_controller/translations/en.json @@ -0,0 +1,80 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure PID Controller" + } + }, + "error": { + "already_configured": "A configuration with this name already exists." + }, + "field": { + "name": "Name", + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" + } + }, + "options": { + "step": { + "init": { + "title": "Configure PID Controller Options" + } + }, + "field": { + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" + } + }, + "entity": { + "number": { + "simple_pid_controller": { + "kp": { + "name": "Kp", + "description": "Proportional gain" + }, + "ki": { + "name": "Ki", + "description": "Integral gain" + }, + "kd": { + "name": "Kd", + "description": "Derivative gain" + }, + "setpoint": { + "name": "Setpoint", + "description": "Desired value for the controller" + }, + "output": { + "name": "Output", + "description": "Controller output value" + } + } + }, + "switch": { + "simple_pid_controller": { + "auto_mode": { + "name": "Auto Mode", + "description": "Enable automatic PID mode" + }, + "proportional_on_measurement": { + "name": "Proportional on Measurement", + "description": "Use proportional-on-measurement mode" + }, + "windup_protection": { + "name": "Windup Protection", + "description": "Enable windup protection" + } + } + }, + "sensor": { + "simple_pid_controller": { + "current_value": { + "name": "Current Value", + "description": "Current value measured by the sensor" + } + } + } + } +} \ No newline at end of file From 2a29387400975a7557b0a268b327506bb147d72e Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 20:12:37 +0200 Subject: [PATCH 30/57] Revert "Create en.json" This reverts commit 1fb873c1089098d70288234d169da5c625146f0d. --- .../translations/en.json | 80 ------------------- 1 file changed, 80 deletions(-) delete mode 100644 custom_components/simple_pid_controller/translations/en.json diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json deleted file mode 100644 index 1f5b5fa..0000000 --- a/custom_components/simple_pid_controller/translations/en.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Configure PID Controller" - } - }, - "error": { - "already_configured": "A configuration with this name already exists." - }, - "field": { - "name": "Name", - "sensor_entity_id": "Sensor Entity", - "range_min": "Minimum Range", - "range_max": "Maximum Range" - } - }, - "options": { - "step": { - "init": { - "title": "Configure PID Controller Options" - } - }, - "field": { - "sensor_entity_id": "Sensor Entity", - "range_min": "Minimum Range", - "range_max": "Maximum Range" - } - }, - "entity": { - "number": { - "simple_pid_controller": { - "kp": { - "name": "Kp", - "description": "Proportional gain" - }, - "ki": { - "name": "Ki", - "description": "Integral gain" - }, - "kd": { - "name": "Kd", - "description": "Derivative gain" - }, - "setpoint": { - "name": "Setpoint", - "description": "Desired value for the controller" - }, - "output": { - "name": "Output", - "description": "Controller output value" - } - } - }, - "switch": { - "simple_pid_controller": { - "auto_mode": { - "name": "Auto Mode", - "description": "Enable automatic PID mode" - }, - "proportional_on_measurement": { - "name": "Proportional on Measurement", - "description": "Use proportional-on-measurement mode" - }, - "windup_protection": { - "name": "Windup Protection", - "description": "Enable windup protection" - } - } - }, - "sensor": { - "simple_pid_controller": { - "current_value": { - "name": "Current Value", - "description": "Current value measured by the sensor" - } - } - } - } -} \ No newline at end of file From 7be44af3636384edd3ddc1d0728bc1c37354e8db Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 20:21:30 +0200 Subject: [PATCH 31/57] Update translation --- .../translations/en.json | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index 1f5b5fa..14f3e0b 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -31,50 +31,41 @@ "number": { "simple_pid_controller": { "kp": { - "name": "Kp", - "description": "Proportional gain" + "name": "Kp" }, "ki": { - "name": "Ki", - "description": "Integral gain" + "name": "Ki" }, "kd": { - "name": "Kd", - "description": "Derivative gain" + "name": "Kd" }, "setpoint": { - "name": "Setpoint", - "description": "Desired value for the controller" + "name": "Setpoint" }, "output": { - "name": "Output", - "description": "Controller output value" + "name": "Output" } } }, "switch": { "simple_pid_controller": { "auto_mode": { - "name": "Auto Mode", - "description": "Enable automatic PID mode" + "name": "Auto Mode" }, "proportional_on_measurement": { - "name": "Proportional on Measurement", - "description": "Use proportional-on-measurement mode" + "name": "Proportional on Measurement" }, "windup_protection": { - "name": "Windup Protection", - "description": "Enable windup protection" + "name": "Windup Protection" } } }, "sensor": { "simple_pid_controller": { "current_value": { - "name": "Current Value", - "description": "Current value measured by the sensor" + "name": "Current Value" } } } } -} \ No newline at end of file +} From d32fe63f53bb90d509cdcdf1b70603b7c08853cc Mon Sep 17 00:00:00 2001 From: bvweerd Date: Mon, 19 May 2025 21:04:57 +0200 Subject: [PATCH 32/57] Update en.json --- .../translations/en.json | 104 ++++++++---------- 1 file changed, 43 insertions(+), 61 deletions(-) diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index 14f3e0b..9241d8b 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -1,71 +1,53 @@ { - "config": { - "step": { - "user": { - "title": "Configure PID Controller" - } - }, - "error": { - "already_configured": "A configuration with this name already exists." - }, - "field": { - "name": "Name", - "sensor_entity_id": "Sensor Entity", - "range_min": "Minimum Range", - "range_max": "Maximum Range" - } - }, - "options": { - "step": { - "init": { - "title": "Configure PID Controller Options" - } - }, - "field": { - "sensor_entity_id": "Sensor Entity", - "range_min": "Minimum Range", - "range_max": "Maximum Range" - } - }, - "entity": { - "number": { - "simple_pid_controller": { - "kp": { - "name": "Kp" + "config": { + "step": { + "user": { + "title": "Configure PID Controller" + } }, - "ki": { - "name": "Ki" + "error": { + "already_configured": "A configuration with this name already exists." }, - "kd": { - "name": "Kd" - }, - "setpoint": { - "name": "Setpoint" - }, - "output": { - "name": "Output" + "field": { + "name": "Name", + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" } - } }, - "switch": { - "simple_pid_controller": { - "auto_mode": { - "name": "Auto Mode" - }, - "proportional_on_measurement": { - "name": "Proportional on Measurement" + "options": { + "step": { + "init": { + "title": "Configure PID Controller Options" + } }, - "windup_protection": { - "name": "Windup Protection" + "field": { + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" } - } }, - "sensor": { - "simple_pid_controller": { - "current_value": { - "name": "Current Value" + "entity": { + "number": { + "simple_pid_controller": { + "kp": "Kp", + "ki": "Ki", + "kd": "Kd", + "setpoint": "Setpoint", + "output": "Output" + } + }, + "switch": { + "simple_pid_controller": { + "auto_mode": "Auto Mode", + "proportional_on_measurement": "Proportional on Measurement", + "windup_protection": "Windup Protection" + } + }, + "sensor": { + "simple_pid_controller": { + "current_value": "Current Value" + } } - } } - } -} +} \ No newline at end of file From 3c0be062b44ee9fe859fff560f9a260bad74d0f3 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 20 May 2025 05:45:53 +0000 Subject: [PATCH 33/57] new file: custom_components/simple_pid_controller/translations/en.json new file: tests/test_coordinator.py --- .../translations/en.json | 80 +++++++++++++++++++ tests/test_coordinator.py | 29 +++++++ 2 files changed, 109 insertions(+) create mode 100644 custom_components/simple_pid_controller/translations/en.json create mode 100644 tests/test_coordinator.py diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json new file mode 100644 index 0000000..5fb7611 --- /dev/null +++ b/custom_components/simple_pid_controller/translations/en.json @@ -0,0 +1,80 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure PID Controller" + } + }, + "error": { + "already_configured": "A configuration with this name already exists." + }, + "field": { + "name": "Name", + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" + } + }, + "options": { + "step": { + "init": { + "title": "Configure PID Controller Options" + } + }, + "field": { + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" + } + }, + "entity": { + "number": { + "simple_pid_controller": { + "kp": { + "name": "Kp", + "description": "Proportional gain" + }, + "ki": { + "name": "Ki", + "description": "Integral gain" + }, + "kd": { + "name": "Kd", + "description": "Derivative gain" + }, + "setpoint": { + "name": "Setpoint", + "description": "Desired value for the controller" + }, + "output": { + "name": "Output", + "description": "Controller output value" + } + } + }, + "switch": { + "simple_pid_controller": { + "auto_mode": { + "name": "Auto Mode", + "description": "Enable automatic PID mode" + }, + "proportional_on_measurement": { + "name": "Proportional on Measurement", + "description": "Use proportional-on-measurement mode" + }, + "windup_protection": { + "name": "Windup Protection", + "description": "Enable windup protection" + } + } + }, + "sensor": { + "simple_pid_controller": { + "current_value": { + "name": "Current Value", + "description": "Current value measured by the sensor" + } + } + } + } +} diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..f1001d2 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,29 @@ +import pytest +from custom_components.simple_pid_controller.coordinator import PIDDataCoordinator + + +@pytest.mark.asyncio +async def test_coordinator_update_method_assignment(hass): + """Test that the coordinator assigns the update_method and calls it.""" + + # Dummy async update method + called = {} + + async def dummy_update_method(): + called["yes"] = True + return 42.0 + + coordinator = PIDDataCoordinator( + hass=hass, + name="test", + update_method=dummy_update_method, + interval=10, # 10 seconds + ) + + # Check assignment + assert coordinator.update_method == dummy_update_method + + # Call _async_update_data and ensure dummy_update_method is called and result is correct + result = await coordinator._async_update_data() + assert result == 42.0 + assert called["yes"] is True From 9c7f187c32182994b2919560c3348992a44a286d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 20 May 2025 07:52:33 +0200 Subject: [PATCH 34/57] Update en.json --- .../translations/en.json | 107 ++++++++++-------- 1 file changed, 60 insertions(+), 47 deletions(-) diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index 9241d8b..0facf14 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -1,53 +1,66 @@ { - "config": { - "step": { - "user": { - "title": "Configure PID Controller" - } - }, - "error": { - "already_configured": "A configuration with this name already exists." - }, - "field": { - "name": "Name", - "sensor_entity_id": "Sensor Entity", - "range_min": "Minimum Range", - "range_max": "Maximum Range" + "title": "Simple PID Controller", + "config": { + "step": { + "user": { + "title": "Configure Simple PID Controller", + "description": "Enter a name and choose the sensor to drive the PID loop.", + "data": { + "name": "Name", + "sensor_entity_id": "Sensor Entity" } + } }, - "options": { - "step": { - "init": { - "title": "Configure PID Controller Options" - } - }, - "field": { - "sensor_entity_id": "Sensor Entity", - "range_min": "Minimum Range", - "range_max": "Maximum Range" - } + "abort": { + "single_instance_allowed": "Only a single instance of Simple PID Controller is allowed." + }, + "error": { + "already_configured": "A configuration with this name already exists." }, - "entity": { - "number": { - "simple_pid_controller": { - "kp": "Kp", - "ki": "Ki", - "kd": "Kd", - "setpoint": "Setpoint", - "output": "Output" - } - }, - "switch": { - "simple_pid_controller": { - "auto_mode": "Auto Mode", - "proportional_on_measurement": "Proportional on Measurement", - "windup_protection": "Windup Protection" - } - }, - "sensor": { - "simple_pid_controller": { - "current_value": "Current Value" - } + "field": { + "name": "Name", + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" + } + }, + "options": { + "step": { + "init": { + "title": "Edit PID Controller Options", + "description": "Modify the sensor entity used by the controller.", + "data": { + "sensor_entity_id": "Sensor Entity" } + } + }, + "field": { + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" + } + }, + "entity": { + "number": { + "simple_pid_controller": { + "kp": "Kp", + "ki": "Ki", + "kd": "Kd", + "setpoint": "Setpoint", + "output": "Output" + } + }, + "switch": { + "simple_pid_controller": { + "auto_mode": "Auto Mode", + "proportional_on_measurement": "Proportional on Measurement", + "windup_protection": "Windup Protection" + } + }, + "sensor": { + "simple_pid_controller": { + "current_value": "Current Value" + } } -} \ No newline at end of file + } +} From a62cf6eb6043286551c958169359be102c87fede Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 20 May 2025 07:54:34 +0200 Subject: [PATCH 35/57] Update en.json --- .../translations/en.json | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index 0facf14..73dae01 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -16,50 +16,47 @@ }, "error": { "already_configured": "A configuration with this name already exists." - }, - "field": { - "name": "Name", - "sensor_entity_id": "Sensor Entity", - "range_min": "Minimum Range", - "range_max": "Maximum Range" } }, "options": { "step": { "init": { "title": "Edit PID Controller Options", - "description": "Modify the sensor entity used by the controller.", + "description": "Modify the sensor entity and range used by the controller.", "data": { - "sensor_entity_id": "Sensor Entity" + "sensor_entity_id": "Sensor Entity", + "range_min": "Minimum Range", + "range_max": "Maximum Range" } } - }, - "field": { - "sensor_entity_id": "Sensor Entity", - "range_min": "Minimum Range", - "range_max": "Maximum Range" } }, "entity": { "number": { "simple_pid_controller": { - "kp": "Kp", - "ki": "Ki", - "kd": "Kd", - "setpoint": "Setpoint", - "output": "Output" + "attributes": { + "kp": "Kp", + "ki": "Ki", + "kd": "Kd", + "setpoint": "Setpoint", + "output": "Output" + } } }, "switch": { "simple_pid_controller": { - "auto_mode": "Auto Mode", - "proportional_on_measurement": "Proportional on Measurement", - "windup_protection": "Windup Protection" + "attributes": { + "auto_mode": "Auto Mode", + "proportional_on_measurement": "Proportional on Measurement", + "windup_protection": "Windup Protection" + } } }, "sensor": { "simple_pid_controller": { - "current_value": "Current Value" + "attributes": { + "current_value": "Current Value" + } } } } From 76a2fe726d08e90101b5ebf0ac62a74920944e39 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 20 May 2025 08:11:49 +0200 Subject: [PATCH 36/57] Update en.json --- .../translations/en.json | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index 73dae01..f28e08f 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -34,30 +34,25 @@ "entity": { "number": { "simple_pid_controller": { - "attributes": { - "kp": "Kp", - "ki": "Ki", - "kd": "Kd", - "setpoint": "Setpoint", - "output": "Output" - } + "kp": "Kp", + "ki": "Ki", + "kd": "Kd", + "setpoint": "Setpoint", + "output": "Output" } }, "switch": { "simple_pid_controller": { - "attributes": { - "auto_mode": "Auto Mode", - "proportional_on_measurement": "Proportional on Measurement", - "windup_protection": "Windup Protection" - } + "auto_mode": "Auto Mode", + "proportional_on_measurement": "Proportional on Measurement", + "windup_protection": "Windup Protection" } }, "sensor": { "simple_pid_controller": { - "attributes": { - "current_value": "Current Value" - } + "current_value": "Current Value" } } } } + From 4cc0dac95096ee1e7545523caeb639871d8f4986 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 20 May 2025 08:20:22 +0200 Subject: [PATCH 37/57] Update en.json --- .../translations/en.json | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index f28e08f..6c73b98 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -33,26 +33,37 @@ }, "entity": { "number": { - "simple_pid_controller": { - "kp": "Kp", - "ki": "Ki", - "kd": "Kd", - "setpoint": "Setpoint", - "output": "Output" - } + "kp": { + "name": "Kp" + }, + "ki": { + "name": "Ki" + }, + "kd": { + "name": "Kd" + }, + "setpoint": { + "name": "Setpoint" + }, + "output": { + "name": "Output" + } }, "switch": { - "simple_pid_controller": { - "auto_mode": "Auto Mode", - "proportional_on_measurement": "Proportional on Measurement", - "windup_protection": "Windup Protection" - } + "auto_mode": { + "name": "Auto Mode" + }, + "proportional_on_measurement": { + "name": "Proportional on Measurement" + }, + "windup_protection": { + "name": "Windup Protection" + } }, "sensor": { - "simple_pid_controller": { - "current_value": "Current Value" - } + "current_value": { + "name": "Current Value" + } } } } - From e5f7a0109c1fe049b4615e84e7cac74fac7f0149 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 20 May 2025 08:26:07 +0200 Subject: [PATCH 38/57] Add Dutch --- .../translations/en.json | 2 +- .../translations/nl.json | 69 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 custom_components/simple_pid_controller/translations/nl.json diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index 6c73b98..c9a8686 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -12,7 +12,7 @@ } }, "abort": { - "single_instance_allowed": "Only a single instance of Simple PID Controller is allowed." + "single_instance_allowed": "Only a single instance with this name is allowed." }, "error": { "already_configured": "A configuration with this name already exists." diff --git a/custom_components/simple_pid_controller/translations/nl.json b/custom_components/simple_pid_controller/translations/nl.json new file mode 100644 index 0000000..06a0996 --- /dev/null +++ b/custom_components/simple_pid_controller/translations/nl.json @@ -0,0 +1,69 @@ +{ + "title": "PID-regelaar", + "config": { + "step": { + "user": { + "title": "Configureer PID-regelaar", + "description": "Voer een naam in en kies de sensor als input voor de PID.", + "data": { + "name": "Naam", + "sensor_entity_id": "Sensor naam" + } + } + }, + "abort": { + "single_instance_allowed": "Slechts één instantie van de PID-regelaar met deze naam is toegestaan." + }, + "error": { + "already_configured": "Er bestaat al een configuratie met deze naam." + } + }, + "options": { + "step": { + "init": { + "title": "Bewerk PID-regelaar", + "description": "Wijzig de sensor en het bereik dat door de PID-regelaar wordt gebruikt.", + "data": { + "sensor_entity_id": "Sensor naam", + "range_min": "Minimum bereik", + "range_max": "Maximum bereik" + } + } + } + }, + "entity": { + "number": { + "kp": { + "name": "Kp" + }, + "ki": { + "name": "Ki" + }, + "kd": { + "name": "Kd" + }, + "setpoint": { + "name": "Doel" + }, + "output": { + "name": "Uitgang" + } + }, + "switch": { + "auto_mode": { + "name": "Automatische mode" + }, + "proportional_on_measurement": { + "name": "Proportioneel op meting" + }, + "windup_protection": { + "name": "Windup bescherming" + } + }, + "sensor": { + "current_value": { + "name": "Huidige waarde" + } + } + } +} From 291e0869f99e2e04fe1f9082ab9e893082a9aad1 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Tue, 20 May 2025 13:16:56 +0000 Subject: [PATCH 39/57] Changes to be committed: modified: tests/test_coordinator.py modified: tests/test_device_handle.py modified: tests/test_number.py modified: tests/test_sensor.py modified: tests/test_switch.py --- tests/test_coordinator.py | 37 +++++------ tests/test_device_handle.py | 22 +++++++ tests/test_number.py | 123 ++++++++++++++++++++++++++++++++++-- tests/test_sensor.py | 96 +++++++++++++++++++++------- tests/test_switch.py | 34 +++++++++- 5 files changed, 262 insertions(+), 50 deletions(-) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index f1001d2..9e622ee 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -1,29 +1,26 @@ import pytest from custom_components.simple_pid_controller.coordinator import PIDDataCoordinator +from homeassistant.helpers.update_coordinator import UpdateFailed -@pytest.mark.asyncio -async def test_coordinator_update_method_assignment(hass): - """Test that the coordinator assigns the update_method and calls it.""" +async def test_async_update_data_success(hass): + """Test that _async_update_data returns the value from update_method on success.""" - # Dummy async update method - called = {} - - async def dummy_update_method(): - called["yes"] = True + async def fake_update(): return 42.0 - coordinator = PIDDataCoordinator( - hass=hass, - name="test", - update_method=dummy_update_method, - interval=10, # 10 seconds - ) - - # Check assignment - assert coordinator.update_method == dummy_update_method - - # Call _async_update_data and ensure dummy_update_method is called and result is correct + coordinator = PIDDataCoordinator(hass, "test", fake_update, interval=1) result = await coordinator._async_update_data() assert result == 42.0 - assert called["yes"] is True + + +async def test_async_update_data_failure(hass): + """Test that _async_update_data raises UpdateFailed with proper message on exception.""" + + async def fake_update(): + raise ValueError("test error") + + coordinator = PIDDataCoordinator(hass, "test", fake_update, interval=1) + with pytest.raises(UpdateFailed) as excinfo: + await coordinator._async_update_data() + assert "PID update failed: test error" in str(excinfo.value) diff --git a/tests/test_device_handle.py b/tests/test_device_handle.py index 0ed2a6c..2f367bf 100644 --- a/tests/test_device_handle.py +++ b/tests/test_device_handle.py @@ -78,3 +78,25 @@ def test_get_input_sensor_value_invalid(hass, config_entry): # Should handle gracefully and return None assert handle.get_input_sensor_value() is None + + +@pytest.mark.parametrize("state", ["unknown", "unavailable"]) +async def test_get_switch_returns_true_when_state_unavailable( + hass, config_entry, state +): + """Regel 79: get_switch returns True if state 'unknown' or 'unavailable'.""" + fake_entity = f"switch.{config_entry.entry_id}_test_key" + handle = PIDDeviceHandle(hass, config_entry) + # Force existence of entity_id + handle._get_entity_id = lambda platform, key: fake_entity + # State to 'unknown' or 'unavailable' + hass.states.async_set(fake_entity, state) + assert handle.get_switch("test_key") is True + + +async def test_get_switch_returns_true_when_no_entity_configured(hass, config_entry): + """Regel 74: get_switch must return True if _get_entity_id None.""" + handle = PIDDeviceHandle(hass, config_entry) + # Force no entity_id + handle._get_entity_id = lambda platform, key: None + assert handle.get_switch("any_key") is True diff --git a/tests/test_number.py b/tests/test_number.py index 55432eb..7b30581 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -1,7 +1,14 @@ import pytest +import logging from custom_components.simple_pid_controller.number import ( PID_NUMBER_ENTITIES, CONTROL_NUMBER_ENTITIES, + PIDParameterNumber, + ControlParameterNumber, +) +from custom_components.simple_pid_controller.const import ( + DEFAULT_RANGE_MIN, + DEFAULT_RANGE_MAX, ) @@ -14,13 +21,8 @@ async def test_number_platform(hass, config_entry): @pytest.mark.parametrize("desc", PID_NUMBER_ENTITIES) async def test_number_entity_attributes(hass, config_entry, desc): - # Build the entity_id from the entry and the key in the descriptor - print(config_entry.entry_id) entity_id = f"number.{config_entry.entry_id}_{desc['key']}" - - # Check that the entity exists state = hass.states.get(entity_id) - assert state is not None, f"{entity_id} does not exist" attrs = state.attributes @@ -36,3 +38,114 @@ async def test_number_entity_attributes(hass, config_entry, desc): ## Unique ID and name # assert state.object_id == f"{config_entry.entry_id}_{desc['key']}" # assert state.name == desc.get("name", state.name) + + +@pytest.mark.parametrize( + "last_value, expected", + [ + (PID_NUMBER_ENTITIES[0]["min"] - 1, PID_NUMBER_ENTITIES[0]["min"]), + (PID_NUMBER_ENTITIES[0]["max"] + 1, PID_NUMBER_ENTITIES[0]["max"]), + ( + (PID_NUMBER_ENTITIES[0]["min"] + PID_NUMBER_ENTITIES[0]["max"]) / 2, + (PID_NUMBER_ENTITIES[0]["min"] + PID_NUMBER_ENTITIES[0]["max"]) / 2, + ), + ], +) +async def test_async_added_to_hass_clamps_pid_value( + hass, config_entry, monkeypatch, last_value, expected +): + """Test that restored PIDParameterNumber clamps values outside range to min/max.""" + desc = PID_NUMBER_ENTITIES[0] + num = PIDParameterNumber(hass, config_entry, desc) + + async def fake_get_last_number_data(): + return type("Last", (), {"native_value": last_value}) + + monkeypatch.setattr(num, "async_get_last_number_data", fake_get_last_number_data) + + await num.async_added_to_hass() + assert num.native_value == expected + + +async def test_async_added_to_hass_clamps_control_value( + hass, config_entry, monkeypatch +): + """Test that restored ControlParameterNumber clamps values outside dynamic range to min/max.""" + for desc in CONTROL_NUMBER_ENTITIES: + num = ControlParameterNumber(hass, config_entry, desc) + min_val = num._attr_native_min_value + max_val = num._attr_native_max_value + + # Below minimum should clamp to min_val + async def fake_last_below(): + return type("Last", (), {"native_value": min_val - 1}) + + monkeypatch.setattr(num, "async_get_last_number_data", fake_last_below) + await num.async_added_to_hass() + assert num.native_value == min_val + + # Above maximum should clamp to max_val + async def fake_last_above(): + return type("Last", (), {"native_value": max_val + 1}) + + monkeypatch.setattr(num, "async_get_last_number_data", fake_last_above) + await num.async_added_to_hass() + assert num.native_value == max_val + + # Value within range should be set as is + mid = (min_val + max_val) / 2 + + async def fake_last_within(): + return type("Last", (), {"native_value": mid}) + + monkeypatch.setattr(num, "async_get_last_number_data", fake_last_within) + await num.async_added_to_hass() + assert num.native_value == mid + + +@pytest.mark.parametrize( + "clazz, desc, sample_value", + [ + (PIDParameterNumber, PID_NUMBER_ENTITIES[0], PID_NUMBER_ENTITIES[0]["default"]), + ( + ControlParameterNumber, + CONTROL_NUMBER_ENTITIES[0], + CONTROL_NUMBER_ENTITIES[0]["default"], + ), + ], +) +async def test_async_set_native_value_triggers_write( + hass, config_entry, monkeypatch, clazz, desc, sample_value +): + """Test that async_set_native_value sets value and calls async_write_ha_state.""" + num = clazz(hass, config_entry, desc) + write_calls = [] + + def fake_write_state(): + write_calls.append(True) + + monkeypatch.setattr(num, "async_write_ha_state", fake_write_state) + + await num.async_set_native_value(sample_value) + assert num.native_value == sample_value + assert write_calls, "async_write_ha_state was not called" + + +@pytest.mark.parametrize("invalid_key", ["invalid1", "invalid2"]) +async def test_controlparameter_number_unexpected_key( + hass, config_entry, caplog, invalid_key +): + """Test that ControlParameterNumber logs error and uses default range for unexpected key.""" + desc = { + "name": "Invalid", + "key": invalid_key, + "unit": "", + "step": 1.0, + "default": 0.0, + "entity_category": None, + } + caplog.set_level(logging.ERROR) + num = ControlParameterNumber(hass, config_entry, desc) + assert f"Unexpected PID parameter key: {invalid_key}" in caplog.text + assert num._attr_native_min_value == DEFAULT_RANGE_MIN + assert num._attr_native_max_value == DEFAULT_RANGE_MAX diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 6c8c7ad..3ca3609 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -3,16 +3,18 @@ from homeassistant.util.dt import utcnow from pytest_homeassistant_custom_component.common import async_fire_time_changed from custom_components.simple_pid_controller.const import DOMAIN +from custom_components.simple_pid_controller.sensor import PIDContributionSensor +from custom_components.simple_pid_controller.coordinator import PIDDataCoordinator +from custom_components.simple_pid_controller.sensor import async_setup_entry @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.""" - # Set sample time sample_time = 5 - # Stub the handle handle = hass.data[DOMAIN][config_entry.entry_id] + handle.get_input_sensor_value = lambda: 10.0 handle.get_number = lambda key: { "kp": 1.0, @@ -25,41 +27,89 @@ async def test_pid_output_and_contributions_update(hass, config_entry): }[key] handle.get_switch = lambda key: True - # 1) trigger update + # 1) trigger initial update hass.bus.async_fire("homeassistant_started") await hass.async_block_till_done() - # 2) fake sample_time later in time + # 2) fake time advancement to trigger scheduled update future = utcnow() + timedelta(seconds=sample_time) async_fire_time_changed(hass, future) await hass.async_block_till_done() - # 2nd update done, check output/contributions + # Check PID output sensor out_entity = f"sensor.{config_entry.entry_id}_pid_output" state = hass.states.get(out_entity) assert state is not None assert float(state.state) != 0 -""" @pytest.mark.asyncio -async def test_contribution_sensors_native_value(sensor_entities, hass, config_entry): - # Find all PIDContributionSensor-instances - contrib = [ - s for s in sensor_entities - if isinstance(s, PIDContributionSensor) +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.""" + handle = hass.data[DOMAIN][config_entry.entry_id] + # Provide known contributions + handle.last_contributions = (0.1234, 1.9876, 2.5555) + coordinator = PIDDataCoordinator(hass, "test", lambda: 0, interval=1) + + # Map contribution key to expected index + mapping = [ + ("p", round(0.1234, 2)), + ("i", round(1.9876, 2)), + ("d", round(2.5555, 2)), ] - assert len(contrib) == 3 + for key, expected in mapping: + sensor = PIDContributionSensor( + hass, config_entry, key, f"pid_{key}_contrib", handle, coordinator + ) + # Override internal handle to use test handle + sensor._handle = handle + assert sensor.native_value == expected + + # Unknown key should return None + sensor_none = PIDContributionSensor( + hass, config_entry, "x", "pid_x_contrib", handle, coordinator + ) + sensor_none._handle = handle + assert sensor_none.native_value is None - # set to known tuple + +@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.""" + # Prepare handle handle = hass.data[DOMAIN][config_entry.entry_id] - handle.last_contributions = (1.234, 2.345, 3.456) - - # Check native_value() - for sensor_obj, exp in zip( - sorted(contrib_sensors, key=lambda s: s._component), - (0.24, 0.22, 3.46), - ): - print(sensor_obj.native_value) - assert sensor_obj.native_value == exp -""" + handle.get_input_sensor_value = lambda: 0.0 + handle.get_number = lambda key: 0.0 + handle.get_switch = lambda key: True + + # Capture listeners + listeners = [] + monkeypatch.setattr( + type(hass.bus), + "async_listen", + lambda self, event, cb: listeners.append((event, cb)), + ) + + # Run setup to register listeners + entities = [] + await async_setup_entry(hass, config_entry, lambda ents: entities.extend(ents)) + coordinator = entities[0].coordinator + + # Patch refresh method + called = [] + monkeypatch.setattr( + coordinator, "async_request_refresh", lambda: called.append(True) + ) + + # Simulate state change event for kp + entry_id = config_entry.entry_id + test_entity = f"number.{entry_id}_kp" + callback = next(cb for evt, cb in listeners if evt == "state_changed") + + from types import SimpleNamespace + + event = SimpleNamespace(data={"entity_id": test_entity}) + callback(event) + assert ( + called + ), "Coordinator.async_request_refresh was not called on sensor state change" diff --git a/tests/test_switch.py b/tests/test_switch.py index 17986d4..e659abd 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -1,5 +1,8 @@ import pytest -from custom_components.simple_pid_controller.switch import SWITCH_ENTITIES +from custom_components.simple_pid_controller.switch import ( + SWITCH_ENTITIES, + PIDOptionSwitch, +) @pytest.mark.asyncio @@ -8,13 +11,14 @@ async def test_switch_operations(hass, config_entry): for desc in SWITCH_ENTITIES: entity_id = f"switch.{config_entry.entry_id}_{desc['key']}" - # Default state should be 'on' + # Default state should match description state = hass.states.get(entity_id) assert state is not None, f"Switch {entity_id} does not exist" if desc["default_state"]: assert state.state == "on" else: assert state.state == "off" + # Turn off and verify await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True @@ -26,3 +30,29 @@ async def test_switch_operations(hass, config_entry): "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) assert hass.states.get(entity_id).state == "on" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("last_state, expected", [("on", True), ("off", False)]) +async def test_async_added_to_hass_restores_previous_state( + hass, config_entry, monkeypatch, last_state, expected +): + """Test that PIDOptionSwitch.async_added_to_hass restores previous state (lines 47-48).""" + # Use first entity descriptor + desc = SWITCH_ENTITIES[0] + switch = PIDOptionSwitch(hass, config_entry, desc) + + # Fake a previous state + class LastState: + def __init__(self, state): + self.state = state + + async def fake_get_last_state(): + return LastState(last_state) + + monkeypatch.setattr(switch, "async_get_last_state", fake_get_last_state) + # Set initial state opposite to expected to ensure restoration occurs + switch._state = not expected + + await switch.async_added_to_hass() + assert switch.is_on is expected From eb96e51ecc4625842ff79cbee5fb8bee0a5c87f7 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 21 May 2025 09:50:11 +0200 Subject: [PATCH 40/57] Add validation in config flow --- .../simple_pid_controller/config_flow.py | 64 ++++++++++++------- .../translations/en.json | 6 +- .../translations/nl.json | 6 +- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index 269b1ce..7efb52a 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -47,9 +47,33 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" + + schema=vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_SENSOR_ENTITY_ID): selector( + {"entity": {"domain": "sensor"}} + ), + vol.Optional(CONF_RANGE_MIN, default=DEFAULT_RANGE_MIN): vol.Coerce( + float + ), + vol.Optional(CONF_RANGE_MAX, default=DEFAULT_RANGE_MAX): vol.Coerce( + float + ), + } + ) + if user_input is not None: self._async_abort_entries_match({CONF_NAME: user_input[CONF_NAME]}) + # Validate that range_min < range_max + min_val = user_input.get(CONF_RANGE_MIN) + max_val = user_input.get(CONF_RANGE_MAX) + if min_val is not None and max_val is not None and min_val >= max_val: + return self.async_show_form( + step_id="user", data_schema=schema, errors={"base": "range_min_max"} + ) + return self.async_create_entry( title=user_input[CONF_NAME], data={ @@ -58,23 +82,7 @@ async def async_step_user( }, ) - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_SENSOR_ENTITY_ID): selector( - {"entity": {"domain": "sensor"}} - ), - vol.Optional(CONF_RANGE_MIN, default=DEFAULT_RANGE_MIN): vol.Coerce( - float - ), - vol.Optional(CONF_RANGE_MAX, default=DEFAULT_RANGE_MAX): vol.Coerce( - float - ), - } - ), - ) + return self.async_show_form(step_id="user", data_schema=schema ) class PIDControllerOptionsFlowHandler(OptionsFlow): @@ -88,12 +96,6 @@ async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options form and save user input.""" - # If the user has submitted the form, create the entry - if user_input is not None: - return self.async_create_entry( - title=self.config_entry.title, - data=user_input, - ) # Pre-fill form with existing options or sensible defaults current_sensor = self.config_entry.options.get( @@ -119,4 +121,20 @@ async def async_step_init( } ) + # If the user has submitted the form, create the entry + if user_input is not None: + + # Validate that range_min < range_max + min_val = user_input.get(CONF_RANGE_MIN) + max_val = user_input.get(CONF_RANGE_MAX) + if min_val is not None and max_val is not None and min_val >= max_val: + return self.async_show_form( + step_id="init", data_schema=options_schema, errors={"base": "range_min_max"} + ) + + return self.async_create_entry( + title=self.config_entry.title, + data=user_input, + ) + return self.async_show_form(step_id="init", data_schema=options_schema) diff --git a/custom_components/simple_pid_controller/translations/en.json b/custom_components/simple_pid_controller/translations/en.json index bfc2321..5b25d3c 100644 --- a/custom_components/simple_pid_controller/translations/en.json +++ b/custom_components/simple_pid_controller/translations/en.json @@ -15,7 +15,8 @@ "single_instance_allowed": "Only a single instance with this name is allowed." }, "error": { - "already_configured": "A configuration with this name already exists." + "already_configured": "A configuration with this name already exists.", + "range_min_max": "Minimum must be lower than maximum." } }, "options": { @@ -29,6 +30,9 @@ "range_max": "Maximum Range" } } + }, + "error": { + "range_min_max": "Minimum must be lower than maximum." } }, "entity": { diff --git a/custom_components/simple_pid_controller/translations/nl.json b/custom_components/simple_pid_controller/translations/nl.json index 06a0996..1ff0624 100644 --- a/custom_components/simple_pid_controller/translations/nl.json +++ b/custom_components/simple_pid_controller/translations/nl.json @@ -15,7 +15,8 @@ "single_instance_allowed": "Slechts één instantie van de PID-regelaar met deze naam is toegestaan." }, "error": { - "already_configured": "Er bestaat al een configuratie met deze naam." + "already_configured": "Er bestaat al een configuratie met deze naam.", + "range_min_max": "Minimum moet lager zijn dan maximum." } }, "options": { @@ -29,6 +30,9 @@ "range_max": "Maximum bereik" } } + }, + "error": { + "range_min_max": "Minimum moet lager zijn dan maximum." } }, "entity": { From e4c97d09ff6c5fda58bd20494f71f40a666c864c Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 21 May 2025 08:42:01 +0000 Subject: [PATCH 41/57] Added tests including edge cases for config_flow. modified: custom_components/simple_pid_controller/config_flow.py modified: custom_components/simple_pid_controller/quality_scale.yaml modified: tests/test_config_flow.py --- .../simple_pid_controller/config_flow.py | 19 ++- .../simple_pid_controller/quality_scale.yaml | 12 +- tests/test_config_flow.py | 160 ++++++++++++++---- 3 files changed, 140 insertions(+), 51 deletions(-) diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index 7efb52a..de00828 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -47,8 +47,8 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - - schema=vol.Schema( + + schema = vol.Schema( { vol.Required(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_SENSOR_ENTITY_ID): selector( @@ -62,7 +62,7 @@ async def async_step_user( ), } ) - + if user_input is not None: self._async_abort_entries_match({CONF_NAME: user_input[CONF_NAME]}) @@ -73,7 +73,7 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=schema, errors={"base": "range_min_max"} ) - + return self.async_create_entry( title=user_input[CONF_NAME], data={ @@ -82,7 +82,7 @@ async def async_step_user( }, ) - return self.async_show_form(step_id="user", data_schema=schema ) + return self.async_show_form(step_id="user", data_schema=schema) class PIDControllerOptionsFlowHandler(OptionsFlow): @@ -123,18 +123,19 @@ async def async_step_init( # If the user has submitted the form, create the entry if user_input is not None: - # Validate that range_min < range_max min_val = user_input.get(CONF_RANGE_MIN) max_val = user_input.get(CONF_RANGE_MAX) if min_val is not None and max_val is not None and min_val >= max_val: return self.async_show_form( - step_id="init", data_schema=options_schema, errors={"base": "range_min_max"} + step_id="init", + data_schema=options_schema, + errors={"base": "range_min_max"}, ) - + return self.async_create_entry( title=self.config_entry.title, data=user_input, ) - + return self.async_show_form(step_id="init", data_schema=options_schema) diff --git a/custom_components/simple_pid_controller/quality_scale.yaml b/custom_components/simple_pid_controller/quality_scale.yaml index 4926b79..918b695 100644 --- a/custom_components/simple_pid_controller/quality_scale.yaml +++ b/custom_components/simple_pid_controller/quality_scale.yaml @@ -3,13 +3,13 @@ rules: action-setup: status: exempt comment: This integration does not provide any actions - appropriate-polling: + appropriate-polling: status: exempt comment: This integration does not use polling brands: done common-modules: done - config-flow-test-coverage: todo - config-flow: todo + config-flow-test-coverage: done + config-flow: done dependency-transparency: done docs-actions: done docs-high-level-description: done @@ -21,8 +21,8 @@ rules: runtime-data: todo test-before-configure: status: exempt - comment: This integration has no external dependenc - test-before-setup: + comment: This integration has no external dependencies + test-before-setup: status: exempt comment: This integration has no external dependencies unique-config-entry: done @@ -36,7 +36,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: done - reauthentication-flow: + reauthentication-flow: status: exempt comment: This integration does not use authentication test-coverage: todo diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e767838..3c7fb93 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -5,62 +5,150 @@ from custom_components.simple_pid_controller.const import ( DOMAIN, - CONF_SENSOR_ENTITY_ID, CONF_NAME, + CONF_SENSOR_ENTITY_ID, CONF_RANGE_MIN, CONF_RANGE_MAX, ) +from custom_components.simple_pid_controller.config_flow import ( + PIDControllerFlowHandler, + PIDControllerOptionsFlowHandler, +) + +SENSOR_ENTITY = "sensor.test_input" @pytest.mark.parametrize( - "source,step_id", + "user_input, expected_type, expected_data, expected_errors", [ - (config_entries.SOURCE_USER, "user"), + # Happy path without specifying ranges + ( + {CONF_NAME: "My PID", CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY}, + FlowResultType.CREATE_ENTRY, + {CONF_NAME: "My PID", CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY}, + None, + ), + # Happy path specifying explicit valid ranges (ignored in create) + ( + { + CONF_NAME: "My PID 2", + CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY, + CONF_RANGE_MIN: 1.0, + CONF_RANGE_MAX: 10.0, + }, + FlowResultType.CREATE_ENTRY, + {CONF_NAME: "My PID 2", CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY}, + None, + ), + # Invalid ranges (min >= max) + ( + { + CONF_NAME: "Bad PID", + CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY, + CONF_RANGE_MIN: 10.0, + CONF_RANGE_MAX: 5.0, + }, + FlowResultType.FORM, + None, + {"base": "range_min_max"}, + ), ], ) -async def test_show_form(hass, source, step_id): - """When starting the flow, you get a form back.""" +async def test_async_step_user( + hass, user_input, expected_type, expected_data, expected_errors +): + """Test the user step: happy paths and validation errors.""" + # Start the user flow result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": source} - ) - assert result["type"] == "form" - assert result["step_id"] == step_id - - -async def test_create_entry(hass): - """After filling out the form, a ConfigEntry is created.""" - init = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Submit the form + flow_id = result["flow_id"] result2 = await hass.config_entries.flow.async_configure( - init["flow_id"], - user_input={CONF_NAME: "My PID", CONF_SENSOR_ENTITY_ID: "sensor.test"}, + flow_id, user_input=user_input ) - assert result2["type"] == "create_entry" - assert result2["title"] == "My PID" - assert result2["data"][CONF_SENSOR_ENTITY_ID] == "sensor.test" + + # Verify outcome + assert result2["type"] == expected_type + if expected_type == FlowResultType.CREATE_ENTRY: + assert result2["title"] == user_input[CONF_NAME] + assert result2["data"] == expected_data + else: + assert result2.get("errors") == expected_errors + + +def test_async_get_options_flow(): + """Test that async_get_options_flow returns the correct handler.""" + handler = PIDControllerFlowHandler() + options_flow = handler.async_get_options_flow(config_entry=None) + assert isinstance(options_flow, PIDControllerOptionsFlowHandler) -async def test_options_flow(hass, config_entry): - """After submitting the OptionsFlow, entry.options is updated.""" - # 1) Start de options flow via de dedicated helper +@pytest.mark.parametrize( + "new_options, expected_errors", + [ + # Valid options update + ( + { + CONF_SENSOR_ENTITY_ID: "sensor.new", + CONF_RANGE_MIN: 1.0, + CONF_RANGE_MAX: 10.0, + }, + None, + ), + # Invalid options ranges + ( + { + CONF_SENSOR_ENTITY_ID: "sensor.new", + CONF_RANGE_MIN: 10.0, + CONF_RANGE_MAX: 5.0, + }, + {"base": "range_min_max"}, + ), + ], +) +async def test_options_flow(hass, config_entry, new_options, expected_errors): + """Test the options flow: happy and error scenarios.""" + # Initialize options flow init_result = await hass.config_entries.options.async_init(config_entry.entry_id) assert init_result["type"] == FlowResultType.FORM assert init_result["step_id"] == "init" - # 2) Dien nieuwe opties in - new_options = { - CONF_SENSOR_ENTITY_ID: "sensor.new", - CONF_RANGE_MIN: 1.0, - CONF_RANGE_MAX: 10.0, - } - finish_result = await hass.config_entries.options.async_configure( - init_result["flow_id"], - user_input=new_options, + # Submit options + result2 = await hass.config_entries.options.async_configure( + init_result["flow_id"], user_input=new_options + ) + + if expected_errors: + assert result2["type"] == FlowResultType.FORM + assert result2.get("errors") == expected_errors + else: + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2.get("data") == new_options + + +async def test_user_flow_duplicate_abort(hass): + """Test that a duplicate config entry aborts the flow.""" + user_input = {CONF_NAME: "Duplicate PID", CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY} + + # Create initial entry + init_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input=user_input + ) + + # Attempt duplicate + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input ) - # 3) Verifieer dat de flow CREATE_ENTRY teruggeeft en options zijn bijgewerkt - assert finish_result["type"] == FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_SENSOR_ENTITY_ID] == "sensor.new" - assert config_entry.options[CONF_RANGE_MIN] == 1.0 - assert config_entry.options[CONF_RANGE_MAX] == 10.0 + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" From 52100bd20210a91ed62dc95c09020a43e60e7d1d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 21 May 2025 09:45:52 +0000 Subject: [PATCH 42/57] added tests to match 100% test coverage --- tests/test_sensor.py | 60 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 3ca3609..2f9eadb 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -113,3 +113,63 @@ async def test_listeners_trigger_refresh_sensor(hass, config_entry, monkeypatch) assert ( called ), "Coordinator.async_request_refresh was not called on sensor state change" + + +@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.""" + handle = hass.data[DOMAIN][config_entry.entry_id] + # Force no input value + handle.get_input_sensor_value = lambda: None + # Provide defaults for numbers and switches + handle.get_number = lambda key: 0.0 + handle.get_switch = lambda key: True + # Setup entry to get coordinator with update_method + entities: list = [] + await async_setup_entry(hass, config_entry, lambda e: entities.extend(e)) + coordinator = entities[0].coordinator + with pytest.raises(ValueError) as excinfo: + await coordinator.update_method() + assert "Input sensor not available" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_update_pid_output_limits_none_when_windup_protection_disabled( + monkeypatch, hass, config_entry +): + """Line 68: update_pid should set output_limits to (None, None) when windup_protection is False.""" + from custom_components.simple_pid_controller import sensor as sensor_module + + # Dummy PID to inspect output_limits + class DummyPID: + def __init__(self, kp, ki, kd, setpoint): + self._proportional = 1.0 + self._integral = 1.0 + self._last_output = None + self.tunings = (kp, ki, kd) + self.setpoint = setpoint + self.sample_time = 1.0 + self.output_limits = (0.0, 0.0) + self.auto_mode = True + self.proportional_on_measurement = False + + def __call__(self, input_value): + return 0.0 + + monkeypatch.setattr(sensor_module, "PID", DummyPID) + handle = hass.data[DOMAIN][config_entry.entry_id] + # Provide valid input and parameters + handle.get_input_sensor_value = lambda: 1.0 + handle.get_number = lambda key: 0.0 + # windup_protection False, other switches True + handle.get_switch = lambda key: False if key == "windup_protection" else True + entities: list = [] + await async_setup_entry(hass, config_entry, lambda e: entities.extend(e)) + coordinator = entities[0].coordinator + # Execute update_method + await coordinator.update_method() + # Extract pid from closure + freevars = coordinator.update_method.__code__.co_freevars + pid_idx = freevars.index("pid") + pid = coordinator.update_method.__closure__[pid_idx].cell_contents + assert pid.output_limits == (None, None) From 010437d3a3d72aad3f889b3d8f4145a7b7763c8d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 21 May 2025 19:19:02 +0200 Subject: [PATCH 43/57] Migrate to runtime_data --- .../simple_pid_controller/__init__.py | 20 +++++++++++++------ .../simple_pid_controller/sensor.py | 15 +++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index e8a2549..b57784c 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er +from dataclasses import dataclass from .const import ( DOMAIN, @@ -94,6 +95,13 @@ def get_input_sensor_value(self) -> float | None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Simple PID Controller from a config entry.""" + @dataclass + class MyData: + handle: PIDDeviceHandle + coordinator: PIDDataCoordinator + + entry.runtime_data = MyData(handle, coordinator) + sensor_entity_id = entry.options.get( CONF_SENSOR_ENTITY_ID, entry.data.get(CONF_SENSOR_ENTITY_ID) ) @@ -103,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Sensor {sensor_entity_id} not ready") handle = PIDDeviceHandle(hass, entry) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = handle + entry.runtime_data.handle = handle # register updatelistener for optionsflow entry.async_on_unload(entry.add_update_listener(_async_update_options_listener)) @@ -113,11 +121,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload PID Controller entry.""" - if DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN]: - hass.data[DOMAIN].pop(entry.entry_id) - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - + """Unload a config entry.""" + if (unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS)) + entry.runtime_data.listener() + return unload_ok + async def _async_update_options_listener( hass: HomeAssistant, entry: ConfigEntry diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 364383d..c2fd9dc 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -32,7 +32,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up PID output and diagnostic sensors.""" - handle: PIDDeviceHandle = hass.data[DOMAIN][entry.entry_id] + handle: PIDDeviceHandle = entry.runtime_data.handle name = handle.name # Init PID with default values @@ -101,25 +101,27 @@ async def update_pid(): # Setup Coordinator coordinator = PIDDataCoordinator(hass, name, update_pid, interval=10) - + entry.runtime_data.coordinator = coordinator # Takes care of proper cleanup on unload + # Wait for HA to finish starting async def start_refresh(_: Any) -> None: _LOGGER.debug("Home Assistant started, first PID-refresh started") await coordinator.async_request_refresh() - hass.bus.async_listen_once("homeassistant_started", start_refresh) + unsub = hass.bus.async_listen_once("homeassistant_started", start_refresh) + entry.async_on_unload(unsub) # Takes care of proper cleanup on unload async_add_entities( [ PIDOutputSensor(hass, entry, coordinator), PIDContributionSensor( - hass, entry, "pid_p_contrib", "P contribution", handle, coordinator + hass, entry, "pid_p_contrib", "P contribution", coordinator ), PIDContributionSensor( - hass, entry, "pid_i_contrib", "I contribution", handle, coordinator + hass, entry, "pid_i_contrib", "I contribution", coordinator ), PIDContributionSensor( - hass, entry, "pid_d_contrib", "D contribution", handle, coordinator + hass, entry, "pid_d_contrib", "D contribution", coordinator ), ] ) @@ -183,7 +185,6 @@ def __init__( entry: ConfigEntry, key: str, name: str, - handle: PIDDeviceHandle, coordinator: PIDDataCoordinator, ): super().__init__(coordinator) From f285f1386ab8229be2f28fd2e94ff87d99a8f52a Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 21 May 2025 19:33:23 +0200 Subject: [PATCH 44/57] migrate to runtime_data --- custom_components/simple_pid_controller/__init__.py | 3 +-- custom_components/simple_pid_controller/sensor.py | 10 ++++++---- tests/test_init_unload.py | 3 ++- tests/test_sensor.py | 12 ++++++------ 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index b57784c..027cd5f 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -100,7 +100,7 @@ class MyData: handle: PIDDeviceHandle coordinator: PIDDataCoordinator - entry.runtime_data = MyData(handle, coordinator) + entry.runtime_data = MyData(handle=handle, coordinator=coordinator) sensor_entity_id = entry.options.get( CONF_SENSOR_ENTITY_ID, entry.data.get(CONF_SENSOR_ENTITY_ID) @@ -111,7 +111,6 @@ class MyData: raise ConfigEntryNotReady(f"Sensor {sensor_entity_id} not ready") handle = PIDDeviceHandle(hass, entry) - entry.runtime_data.handle = handle # register updatelistener for optionsflow entry.async_on_unload(entry.add_update_listener(_async_update_options_listener)) diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index c2fd9dc..2f0ec8c 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -100,16 +100,18 @@ async def update_pid(): return output # Setup Coordinator - coordinator = PIDDataCoordinator(hass, name, update_pid, interval=10) - entry.runtime_data.coordinator = coordinator # Takes care of proper cleanup on unload + if not hasattr(data, "coordinator"): + entry.runtime_data.coordinator = PIDDataCoordinator(hass, name, update_pid, interval=10) + coordinator = entry.runtime_data.coordinator # Wait for HA to finish starting async def start_refresh(_: Any) -> None: _LOGGER.debug("Home Assistant started, first PID-refresh started") await coordinator.async_request_refresh() - unsub = hass.bus.async_listen_once("homeassistant_started", start_refresh) - entry.async_on_unload(unsub) # Takes care of proper cleanup on unload + entry.async_on_unload( + hass.bus.async_listen_once("homeassistant_started", start_refresh) + ) async_add_entities( [ diff --git a/tests/test_init_unload.py b/tests/test_init_unload.py index 894bda1..e2b427d 100644 --- a/tests/test_init_unload.py +++ b/tests/test_init_unload.py @@ -6,7 +6,8 @@ async def test_setup_and_unload_entry(hass, config_entry): """Test setting up and tearing down the entry.""" - assert config_entry.entry_id in hass.data[DOMAIN] + handle = config_entry.runtime_data.handle + assert config_entry.entry_id in handle assert await async_unload_entry(hass, config_entry) is True await hass.async_block_till_done() diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 2f9eadb..a11eb2a 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -13,8 +13,8 @@ async def test_pid_output_and_contributions_update(hass, config_entry): """Test that PID output and contribution sensors update on Home Assistant start.""" sample_time = 5 - handle = hass.data[DOMAIN][config_entry.entry_id] - + handle = config_entry.runtime_data.handle + handle.get_input_sensor_value = lambda: 10.0 handle.get_number = lambda key: { "kp": 1.0, @@ -46,7 +46,7 @@ async def test_pid_output_and_contributions_update(hass, config_entry): @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.""" - handle = hass.data[DOMAIN][config_entry.entry_id] + handle = config_entry.runtime_data.handle # Provide known contributions handle.last_contributions = (0.1234, 1.9876, 2.5555) coordinator = PIDDataCoordinator(hass, "test", lambda: 0, interval=1) @@ -77,7 +77,7 @@ async def test_pid_contribution_native_value_rounding_and_none(hass, config_entr async def test_listeners_trigger_refresh_sensor(hass, config_entry, monkeypatch): """Lines 131-132: coordinator.async_request_refresh called on sensor state change.""" # Prepare handle - handle = hass.data[DOMAIN][config_entry.entry_id] + handle = config_entry.runtime_data.handle handle.get_input_sensor_value = lambda: 0.0 handle.get_number = lambda key: 0.0 handle.get_switch = lambda key: True @@ -118,7 +118,7 @@ async def test_listeners_trigger_refresh_sensor(hass, config_entry, monkeypatch) @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.""" - handle = hass.data[DOMAIN][config_entry.entry_id] + handle = config_entry.runtime_data.handle # Force no input value handle.get_input_sensor_value = lambda: None # Provide defaults for numbers and switches @@ -157,7 +157,7 @@ def __call__(self, input_value): return 0.0 monkeypatch.setattr(sensor_module, "PID", DummyPID) - handle = hass.data[DOMAIN][config_entry.entry_id] + handle = config_entry.runtime_data.handle # Provide valid input and parameters handle.get_input_sensor_value = lambda: 1.0 handle.get_number = lambda key: 0.0 From dde09cad511e4a57a0e1e59b76b100c757d90481 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 21 May 2025 19:38:15 +0200 Subject: [PATCH 45/57] Update __init__.py --- custom_components/simple_pid_controller/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 027cd5f..bc046d3 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -9,6 +9,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from dataclasses import dataclass +from .coordinator import PIDDataCoordinator from .const import ( DOMAIN, @@ -121,7 +122,7 @@ class MyData: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if (unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS)) + if (unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS)): entry.runtime_data.listener() return unload_ok From e71075f0fcf2b4dc2b2ec6a4c9f2062662aa0b8a Mon Sep 17 00:00:00 2001 From: bvweerd Date: Wed, 21 May 2025 20:10:55 +0200 Subject: [PATCH 46/57] Update __init__.py --- custom_components/simple_pid_controller/__init__.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index bc046d3..79ca24d 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -25,6 +25,10 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SWITCH] +@dataclass +class MyData: + handle: PIDDeviceHandle + coordinator: PIDDataCoordinator class PIDDeviceHandle: """Shared device handle for a PID controller config entry.""" @@ -96,12 +100,6 @@ def get_input_sensor_value(self) -> float | None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Simple PID Controller from a config entry.""" - @dataclass - class MyData: - handle: PIDDeviceHandle - coordinator: PIDDataCoordinator - - entry.runtime_data = MyData(handle=handle, coordinator=coordinator) sensor_entity_id = entry.options.get( CONF_SENSOR_ENTITY_ID, entry.data.get(CONF_SENSOR_ENTITY_ID) @@ -112,6 +110,9 @@ class MyData: raise ConfigEntryNotReady(f"Sensor {sensor_entity_id} not ready") handle = PIDDeviceHandle(hass, entry) + coordinator = PIDDataCoordinator(hass, handle.name, handle.update_pid, interval=10) + + entry.runtime_data = MyData(handle=handle, coordinator=coordinator) # register updatelistener for optionsflow entry.async_on_unload(entry.add_update_listener(_async_update_options_listener)) From e446e93dabd2d02527c94f4bc12c804e38cbd7bb Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 22 May 2025 08:40:14 +0000 Subject: [PATCH 47/57] Changed all to runtime_data --- .../simple_pid_controller/__init__.py | 16 +++++++------- .../simple_pid_controller/sensor.py | 9 ++++---- tests/test_init_unload.py | 22 +++++++++++++++++-- tests/test_sensor.py | 7 +++--- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 79ca24d..037de0a 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -25,10 +25,12 @@ PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SWITCH] + @dataclass class MyData: handle: PIDDeviceHandle - coordinator: PIDDataCoordinator + coordinator: PIDDataCoordinator = None + class PIDDeviceHandle: """Shared device handle for a PID controller config entry.""" @@ -100,7 +102,6 @@ def get_input_sensor_value(self) -> float | None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Simple PID Controller from a config entry.""" - sensor_entity_id = entry.options.get( CONF_SENSOR_ENTITY_ID, entry.data.get(CONF_SENSOR_ENTITY_ID) ) @@ -110,9 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady(f"Sensor {sensor_entity_id} not ready") handle = PIDDeviceHandle(hass, entry) - coordinator = PIDDataCoordinator(hass, handle.name, handle.update_pid, interval=10) - - entry.runtime_data = MyData(handle=handle, coordinator=coordinator) + entry.runtime_data = MyData(handle=handle) # register updatelistener for optionsflow entry.async_on_unload(entry.add_update_listener(_async_update_options_listener)) @@ -123,10 +122,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if (unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS)): - entry.runtime_data.listener() + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + # reset runtime_data zodat tests slagen + entry.runtime_data = None return unload_ok - + async def _async_update_options_listener( hass: HomeAssistant, entry: ConfigEntry diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 2f0ec8c..319e6e5 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -17,7 +17,6 @@ from . import PIDDeviceHandle from .entity import BasePIDEntity -from .const import DOMAIN from .coordinator import PIDDataCoordinator # Coordinator is used to centralize the data updates @@ -100,10 +99,12 @@ async def update_pid(): return output # Setup Coordinator - if not hasattr(data, "coordinator"): - entry.runtime_data.coordinator = PIDDataCoordinator(hass, name, update_pid, interval=10) + if entry.runtime_data.coordinator is None: + entry.runtime_data.coordinator = PIDDataCoordinator( + hass, name, update_pid, interval=10 + ) coordinator = entry.runtime_data.coordinator - + # Wait for HA to finish starting async def start_refresh(_: Any) -> None: _LOGGER.debug("Home Assistant started, first PID-refresh started") diff --git a/tests/test_init_unload.py b/tests/test_init_unload.py index e2b427d..01280bb 100644 --- a/tests/test_init_unload.py +++ b/tests/test_init_unload.py @@ -6,9 +6,27 @@ async def test_setup_and_unload_entry(hass, config_entry): """Test setting up and tearing down the entry.""" + # runtime_data should exist… + assert hasattr(config_entry, "runtime_data") + + # …and it should carry a PIDDeviceHandle… handle = config_entry.runtime_data.handle - assert config_entry.entry_id in handle + from custom_components.simple_pid_controller import PIDDeviceHandle + + assert isinstance(handle, PIDDeviceHandle) + + # …whose .entry has the same entry_id + assert handle.entry.entry_id == config_entry.entry_id + # Unload-entry returned True assert await async_unload_entry(hass, config_entry) is True await hass.async_block_till_done() - assert config_entry.entry_id not in hass.data[DOMAIN] + + # Runtime data is empty + assert ( + not hasattr(config_entry, "runtime_data") or config_entry.runtime_data is None + ) + + # hass Data should be gone + + assert DOMAIN not in hass.data diff --git a/tests/test_sensor.py b/tests/test_sensor.py index a11eb2a..b4f1579 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta from homeassistant.util.dt import utcnow from pytest_homeassistant_custom_component.common import async_fire_time_changed -from custom_components.simple_pid_controller.const import DOMAIN from custom_components.simple_pid_controller.sensor import PIDContributionSensor from custom_components.simple_pid_controller.coordinator import PIDDataCoordinator from custom_components.simple_pid_controller.sensor import async_setup_entry @@ -14,7 +13,7 @@ async def test_pid_output_and_contributions_update(hass, config_entry): sample_time = 5 handle = config_entry.runtime_data.handle - + handle.get_input_sensor_value = lambda: 10.0 handle.get_number = lambda key: { "kp": 1.0, @@ -59,7 +58,7 @@ async def test_pid_contribution_native_value_rounding_and_none(hass, config_entr ] for key, expected in mapping: sensor = PIDContributionSensor( - hass, config_entry, key, f"pid_{key}_contrib", handle, coordinator + hass, config_entry, key, f"pid_{key}_contrib", coordinator ) # Override internal handle to use test handle sensor._handle = handle @@ -67,7 +66,7 @@ async def test_pid_contribution_native_value_rounding_and_none(hass, config_entr # Unknown key should return None sensor_none = PIDContributionSensor( - hass, config_entry, "x", "pid_x_contrib", handle, coordinator + hass, config_entry, "x", "pid_x_contrib", coordinator ) sensor_none._handle = handle assert sensor_none.native_value is None From 052a2e83a5faa661a44cac8155e9b8c52cf214f7 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 22 May 2025 10:53:24 +0200 Subject: [PATCH 48/57] fix config flow setting of min/max during setup --- custom_components/simple_pid_controller/config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/simple_pid_controller/config_flow.py b/custom_components/simple_pid_controller/config_flow.py index de00828..899a446 100644 --- a/custom_components/simple_pid_controller/config_flow.py +++ b/custom_components/simple_pid_controller/config_flow.py @@ -79,6 +79,8 @@ async def async_step_user( data={ CONF_NAME: user_input[CONF_NAME], CONF_SENSOR_ENTITY_ID: user_input[CONF_SENSOR_ENTITY_ID], + CONF_RANGE_MIN: user_input[CONF_RANGE_MIN], + CONF_RANGE_MAX: user_input[CONF_RANGE_MAX], }, ) From 2ffc591501b2377404763612d3216e1ae58f21c3 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 22 May 2025 11:04:50 +0200 Subject: [PATCH 49/57] fix setpoint to 50% of initial value --- 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 8c1c59f..6f5288b 100644 --- a/custom_components/simple_pid_controller/number.py +++ b/custom_components/simple_pid_controller/number.py @@ -181,7 +181,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None: # Initialize current value if self._key == "setpoint": - self._attr_native_value = range_min + (range_max + range_min) * float( + self._attr_native_value = range_min + (range_max - range_min) * float( desc["default"] ) elif self._key == "output_min": From a6e5bcdbdfd6c7f236dd47cab9db569fcf580927 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 22 May 2025 09:18:12 +0000 Subject: [PATCH 50/57] Fixed config flow tests --- tests/test_config_flow.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 3c7fb93..08f593f 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -9,6 +9,8 @@ CONF_SENSOR_ENTITY_ID, CONF_RANGE_MIN, CONF_RANGE_MAX, + DEFAULT_RANGE_MIN, + DEFAULT_RANGE_MAX, ) from custom_components.simple_pid_controller.config_flow import ( PIDControllerFlowHandler, @@ -21,14 +23,24 @@ @pytest.mark.parametrize( "user_input, expected_type, expected_data, expected_errors", [ - # Happy path without specifying ranges + # Happy path without specifying ranges (defaults applied) ( - {CONF_NAME: "My PID", CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY}, + { + CONF_NAME: "My PID", + CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY, + CONF_RANGE_MIN: DEFAULT_RANGE_MIN, + CONF_RANGE_MAX: DEFAULT_RANGE_MAX, + }, FlowResultType.CREATE_ENTRY, - {CONF_NAME: "My PID", CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY}, + { + CONF_NAME: "My PID", + CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY, + CONF_RANGE_MIN: DEFAULT_RANGE_MIN, + CONF_RANGE_MAX: DEFAULT_RANGE_MAX, + }, None, ), - # Happy path specifying explicit valid ranges (ignored in create) + # Happy path specifying explicit valid ranges ( { CONF_NAME: "My PID 2", @@ -37,7 +49,12 @@ CONF_RANGE_MAX: 10.0, }, FlowResultType.CREATE_ENTRY, - {CONF_NAME: "My PID 2", CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY}, + { + CONF_NAME: "My PID 2", + CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY, + CONF_RANGE_MIN: 1.0, + CONF_RANGE_MAX: 10.0, + }, None, ), # Invalid ranges (min >= max) @@ -90,7 +107,6 @@ def test_async_get_options_flow(): @pytest.mark.parametrize( "new_options, expected_errors", [ - # Valid options update ( { CONF_SENSOR_ENTITY_ID: "sensor.new", @@ -99,7 +115,6 @@ def test_async_get_options_flow(): }, None, ), - # Invalid options ranges ( { CONF_SENSOR_ENTITY_ID: "sensor.new", @@ -132,7 +147,12 @@ async def test_options_flow(hass, config_entry, new_options, expected_errors): async def test_user_flow_duplicate_abort(hass): """Test that a duplicate config entry aborts the flow.""" - user_input = {CONF_NAME: "Duplicate PID", CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY} + user_input = { + CONF_NAME: "Duplicate PID", + CONF_SENSOR_ENTITY_ID: SENSOR_ENTITY, + CONF_RANGE_MIN: DEFAULT_RANGE_MIN, + CONF_RANGE_MAX: DEFAULT_RANGE_MAX, + } # Create initial entry init_result = await hass.config_entries.flow.async_init( From c93489ad2bad6e9b9f58818f7e7bba738d308262 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 22 May 2025 11:43:59 +0200 Subject: [PATCH 51/57] Update quality_scale.yaml --- .../simple_pid_controller/quality_scale.yaml | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/custom_components/simple_pid_controller/quality_scale.yaml b/custom_components/simple_pid_controller/quality_scale.yaml index 918b695..5f7d23d 100644 --- a/custom_components/simple_pid_controller/quality_scale.yaml +++ b/custom_components/simple_pid_controller/quality_scale.yaml @@ -15,10 +15,10 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: todo + entity-event-setup: done entity-unique-id: done has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: status: exempt comment: This integration has no external dependencies @@ -28,23 +28,25 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo - config-entry-unloading: todo - docs-configuration-parameters: todo - docs-installation-parameters: todo - entity-unavailable: todo + action-exceptions: + status: exempt + comment: This integration does not use actions + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: status: exempt comment: This integration does not use authentication - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: todo - discovery-update-info: todo + discovery-update-info: status: exempt comment: This integration does not use discoverable devices discovery: From c86717b454d74198c87cdfa08f6bc5bec94e61c4 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Thu, 22 May 2025 11:57:27 +0200 Subject: [PATCH 52/57] added data update explanation --- README.md | 40 +++++++++++++++++++ .../simple_pid_controller/quality_scale.yaml | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 132687b..678091d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - [PID Tuning Guide](#pid-tuning-guide) - [Manual Tuning](#1-manual-trial--error) - [Ziegler–Nichols Method](#2-zieglernichols-method) +- [PID Calculation Frequency and Sample Time](#pid-calculation-frequency-and-sample-time) - [Example PID Graph](#example-pid-graph) - [Support & Development](#support--development) - [Service Actions](#service-actions) @@ -140,6 +141,45 @@ A PID controller continuously corrects the difference between a **setpoint** and +--- + +## PID Calculation Frequency and Sample Time + +This integration recalculates the PID output at a fixed, user-configurable interval to ensure timely and consistent control updates. Internally, both the PID loop and Home Assistant’s data coordinator use the same **Sample Time**, but they each maintain their own timer, so slight differences can occur. + +- **Sample Time** + The minimum number of seconds between successive PID computations. For example, if you set `sample_time: 5`, both the PID controller and the update coordinator are scheduled to fire every 5 seconds. + +### How it works in practice + +1. **Initialization** + - On startup (or when options change), we set up a single `sample_time` value (in seconds). + - We register a periodic callback with Home Assistant’s scheduler (`async_track_time_interval` or `DataUpdateCoordinator`) using that same `sample_time`. + - We also configure the PID algorithm’s internal timer to the same `sample_time`. + +2. **Coordinator Tick** + - Every `sample_time` seconds, Home Assistant’s scheduler invokes our update method. + - We immediately read the current process variable (e.g. temperature sensor) and pass it to the PID logic. + +3. **PID Logic & Output** + - The PID algorithm checks whether at least `sample_time` seconds have elapsed since its own last computation. + - If so, it calculates the Proportional, Integral, and Derivative terms and writes the result to your target entity (e.g. a heater or set-point). + - If not (because the PID’s internal timer hasn’t quite reached the next tick), it skips the computation until its own timer allows it. + +4. **Timer Drift & Overlap** + - Both the coordinator and the PID controller schedule their next run relative to when the current one started. Under heavy load, one callback may run a few milliseconds later than expected. + - Because each timer is independent, occasional “double-ticks” or small gaps can occur: + - If the scheduler drifts early but the PID timer hasn’t yet reached `sample_time`, no computation runs. + - If the PID timer elapses first and the scheduler callback is slightly late, the update happens immediately when the scheduler finally fires. + - Over time, these small variances average out, preserving an approximately consistent interval. + +5. **Adjusting Sample Time** + - Changing `sample_time` in your integration options takes effect at the end of the current interval—no Home Assistant restart is required. + - On the next tick, both the coordinator and the PID logic will use the new interval. + +By using a single **Sample Time** for both scheduling and calculation—and understanding that each component tracks its own clock—you get predictable, evenly-spaced control updates while allowing Home Assistant’s event loop to manage timing drifts gracefully.``` + + --- ## 📈 Example PID Graph diff --git a/custom_components/simple_pid_controller/quality_scale.yaml b/custom_components/simple_pid_controller/quality_scale.yaml index 5f7d23d..dc864f7 100644 --- a/custom_components/simple_pid_controller/quality_scale.yaml +++ b/custom_components/simple_pid_controller/quality_scale.yaml @@ -52,7 +52,7 @@ rules: discovery: status: exempt comment: This integration does not use discoverable devices - docs-data-update: todo + docs-data-update: done docs-examples: todo docs-known-limitations: todo docs-supported-devices: todo From 52c161575f051181938b5188ea0f188b838be4db Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 23 May 2025 07:55:09 +0200 Subject: [PATCH 53/57] update strings --- custom_components/simple_pid_controller/__init__.py | 2 +- custom_components/simple_pid_controller/sensor.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/custom_components/simple_pid_controller/__init__.py b/custom_components/simple_pid_controller/__init__.py index 037de0a..abd6091 100644 --- a/custom_components/simple_pid_controller/__init__.py +++ b/custom_components/simple_pid_controller/__init__.py @@ -94,7 +94,7 @@ def get_input_sensor_value(self) -> float | None: return float(state.state) except ValueError: _LOGGER.warning( - f"Sensor {self.sensor_entity_id} heeft geen geldige waarde. PID-berekening wordt overgeslagen." + f"Sensor {self.sensor_entity_id} invalid value. PID-calculation skipped." ) return None diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 319e6e5..356d035 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -32,7 +32,6 @@ async def async_setup_entry( ) -> None: """Set up PID output and diagnostic sensors.""" handle: PIDDeviceHandle = entry.runtime_data.handle - name = handle.name # Init PID with default values pid = PID(1.0, 0.1, 0.05, setpoint=50) @@ -45,7 +44,7 @@ async def update_pid(): if input_value is None: raise ValueError("Input sensor not available") - # Lees parameters uit de UI + # Read parameters from UI kp = handle.get_number("kp") ki = handle.get_number("ki") kd = handle.get_number("kd") @@ -57,7 +56,7 @@ async def update_pid(): p_on_m = handle.get_switch("proportional_on_measurement") windup_protection = handle.get_switch("windup_protection") - # Pas live de PID-instellingen aan + # adapt PID settings pid.tunings = (kp, ki, kd) pid.setpoint = setpoint pid.sample_time = sample_time @@ -101,7 +100,7 @@ async def update_pid(): # Setup Coordinator if entry.runtime_data.coordinator is None: entry.runtime_data.coordinator = PIDDataCoordinator( - hass, name, update_pid, interval=10 + hass, handle.name, update_pid, interval=10 ) coordinator = entry.runtime_data.coordinator From 9981511a215a65744c7d1f549f0cc8d814798c8d Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 23 May 2025 08:01:32 +0200 Subject: [PATCH 54/57] add diagnostics --- .../simple_pid_controller/diagnostics.py | 26 ++++++++++++++++ tests/test_diagnostics.py | 31 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 custom_components/simple_pid_controller/diagnostics.py create mode 100644 tests/test_diagnostics.py diff --git a/custom_components/simple_pid_controller/diagnostics.py b/custom_components/simple_pid_controller/diagnostics.py new file mode 100644 index 0000000..9d891b4 --- /dev/null +++ b/custom_components/simple_pid_controller/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for Simple PID Controller integration.""" + +from __future__ import annotations + +from typing import Any +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry + +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + handle = entry.runtime_data.handle + + return { + "entry_data": entry.as_dict(), + "data": { + "name": handle.name, + "sensor_entity_id": handle.sensor_entity_id, + "range_min": handle.range_min, + "range_max": handle.range_max, + }, + } diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..27ca993 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,31 @@ +import pytest + +from custom_components.simple_pid_controller.diagnostics import ( + async_get_config_entry_diagnostics, +) +from custom_components.simple_pid_controller.const import ( + DOMAIN, + CONF_SENSOR_ENTITY_ID, + CONF_NAME, + DEFAULT_RANGE_MIN, + DEFAULT_RANGE_MAX, +) + + +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) + + # Controleer de entry_data + entry_data = result.get("entry_data") + assert entry_data["domain"] == DOMAIN + assert entry_data["entry_id"] == config_entry.entry_id + assert entry_data["data"][CONF_SENSOR_ENTITY_ID] == config_entry.data[CONF_SENSOR_ENTITY_ID] + assert entry_data["data"][CONF_NAME] == config_entry.data[CONF_NAME] + + # Controleer de diagnostische payload + data = result.get("data") + assert data["name"] == config_entry.data[CONF_NAME] + assert data["sensor_entity_id"] == config_entry.data[CONF_SENSOR_ENTITY_ID] + assert data["range_min"] == DEFAULT_RANGE_MIN + assert data["range_max"] == DEFAULT_RANGE_MAX From e97714da8d5008f21f2f7918402b10a98b65814f Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 23 May 2025 06:08:48 +0000 Subject: [PATCH 55/57] Verification of diagnostics, update quality scale --- .../simple_pid_controller/manifest.json | 2 +- .../simple_pid_controller/quality_scale.yaml | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/custom_components/simple_pid_controller/manifest.json b/custom_components/simple_pid_controller/manifest.json index b727bac..46d93c4 100644 --- a/custom_components/simple_pid_controller/manifest.json +++ b/custom_components/simple_pid_controller/manifest.json @@ -8,7 +8,7 @@ "homekit": {}, "iot_class": "calculated", "issue_tracker": "https://github.com/bvweerd/simple_pid_controller/issues", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["simple-pid==2.0.1"], "ssdp": [], "version": "1.0.2", diff --git a/custom_components/simple_pid_controller/quality_scale.yaml b/custom_components/simple_pid_controller/quality_scale.yaml index dc864f7..129a7b6 100644 --- a/custom_components/simple_pid_controller/quality_scale.yaml +++ b/custom_components/simple_pid_controller/quality_scale.yaml @@ -28,7 +28,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: + action-exceptions: status: exempt comment: This integration does not use actions config-entry-unloading: done @@ -45,17 +45,19 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: This integration does not use discoverable devices - discovery: + discovery: status: exempt comment: This integration does not use discoverable devices docs-data-update: done docs-examples: todo docs-known-limitations: todo - docs-supported-devices: todo + docs-supported-devices: + status: exempt + comment: This integration does not use devices docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo @@ -66,7 +68,7 @@ rules: entity-translations: todo exception-translations: todo icon-translations: todo - reconfiguration-flow: + reconfiguration-flow: status: exempt comment: This integration does not use external devices repair-issues: todo @@ -74,7 +76,7 @@ rules: # Platinum async-dependency: todo - inject-websession: + inject-websession: status: exempt comment: This integration does not use external devices strict-typing: todo From 8d1b4a944193f7ba2d142fb3a63f526b701bfaf4 Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 23 May 2025 06:16:53 +0000 Subject: [PATCH 56/57] linting error fix --- 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 356d035..6537018 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -56,7 +56,7 @@ async def update_pid(): p_on_m = handle.get_switch("proportional_on_measurement") windup_protection = handle.get_switch("windup_protection") - # adapt PID settings + # adapt PID settings pid.tunings = (kp, ki, kd) pid.setpoint = setpoint pid.sample_time = sample_time From d21131f542ef2d4a1624a4f62cb307e497f6a31a Mon Sep 17 00:00:00 2001 From: bvweerd Date: Fri, 23 May 2025 06:18:18 +0000 Subject: [PATCH 57/57] fix linting --- custom_components/simple_pid_controller/diagnostics.py | 2 -- custom_components/simple_pid_controller/sensor.py | 2 +- tests/test_diagnostics.py | 7 ++++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/custom_components/simple_pid_controller/diagnostics.py b/custom_components/simple_pid_controller/diagnostics.py index 9d891b4..204ff49 100644 --- a/custom_components/simple_pid_controller/diagnostics.py +++ b/custom_components/simple_pid_controller/diagnostics.py @@ -6,8 +6,6 @@ from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry -from .const import DOMAIN - async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry diff --git a/custom_components/simple_pid_controller/sensor.py b/custom_components/simple_pid_controller/sensor.py index 356d035..6537018 100644 --- a/custom_components/simple_pid_controller/sensor.py +++ b/custom_components/simple_pid_controller/sensor.py @@ -56,7 +56,7 @@ async def update_pid(): p_on_m = handle.get_switch("proportional_on_measurement") windup_protection = handle.get_switch("windup_protection") - # adapt PID settings + # adapt PID settings pid.tunings = (kp, ki, kd) pid.setpoint = setpoint pid.sample_time = sample_time diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 27ca993..8bf9255 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -1,5 +1,3 @@ -import pytest - from custom_components.simple_pid_controller.diagnostics import ( async_get_config_entry_diagnostics, ) @@ -20,7 +18,10 @@ async def test_config_entry_diagnostics(hass, config_entry): entry_data = result.get("entry_data") assert entry_data["domain"] == DOMAIN assert entry_data["entry_id"] == config_entry.entry_id - assert entry_data["data"][CONF_SENSOR_ENTITY_ID] == config_entry.data[CONF_SENSOR_ENTITY_ID] + assert ( + entry_data["data"][CONF_SENSOR_ENTITY_ID] + == config_entry.data[CONF_SENSOR_ENTITY_ID] + ) assert entry_data["data"][CONF_NAME] == config_entry.data[CONF_NAME] # Controleer de diagnostische payload