Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/label-prs.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Label PRs

on:
pull_request:
pull_request_target:
types: [opened, synchronize, reopened]

permissions:
Expand Down
18 changes: 18 additions & 0 deletions custom_components/simple_pid_controller/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
CONF_INPUT_RANGE_MAX,
CONF_OUTPUT_RANGE_MIN,
CONF_OUTPUT_RANGE_MAX,
CONF_SETPOINT_STEP,
DEFAULT_INPUT_RANGE_MIN,
DEFAULT_INPUT_RANGE_MAX,
DEFAULT_OUTPUT_RANGE_MIN,
DEFAULT_OUTPUT_RANGE_MAX,
DEFAULT_SETPOINT_STEP,
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -145,6 +147,9 @@ async def async_step_init(
current_output_max = self.config_entry.options.get(
CONF_OUTPUT_RANGE_MAX, DEFAULT_OUTPUT_RANGE_MAX
)
current_setpoint_step = self.config_entry.options.get(
CONF_SETPOINT_STEP, DEFAULT_SETPOINT_STEP
)

options_schema = vol.Schema(
{
Expand All @@ -168,6 +173,19 @@ async def async_step_init(
CONF_OUTPUT_RANGE_MAX,
default=current_output_max,
): vol.Coerce(float),
vol.Optional(
CONF_SETPOINT_STEP,
default=current_setpoint_step,
): selector(
{
"number": {
"min": 0.01,
"max": 10.0,
"step": 0.01,
"mode": "box",
}
}
),
}
)

Expand Down
2 changes: 2 additions & 0 deletions custom_components/simple_pid_controller/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
CONF_INPUT_RANGE_MAX = "input_range_max"
CONF_OUTPUT_RANGE_MIN = "output_range_min"
CONF_OUTPUT_RANGE_MAX = "output_range_max"
CONF_SETPOINT_STEP = "setpoint_step"

DEFAULT_INPUT_RANGE_MIN = 0.0
DEFAULT_INPUT_RANGE_MAX = 100.0
DEFAULT_OUTPUT_RANGE_MIN = 0.0
DEFAULT_OUTPUT_RANGE_MAX = 100.0
DEFAULT_SETPOINT_STEP = 0.01
63 changes: 59 additions & 4 deletions custom_components/simple_pid_controller/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,26 @@
from __future__ import annotations

import logging
from typing import Any

from homeassistant.components.number import RestoreNumber
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .entity import BasePIDEntity
from .const import (
CONF_INPUT_RANGE_MIN,
CONF_INPUT_RANGE_MAX,
CONF_OUTPUT_RANGE_MIN,
CONF_OUTPUT_RANGE_MAX,
CONF_SETPOINT_STEP,
DEFAULT_INPUT_RANGE_MIN,
DEFAULT_INPUT_RANGE_MAX,
DEFAULT_OUTPUT_RANGE_MIN,
DEFAULT_OUTPUT_RANGE_MAX,
DEFAULT_SETPOINT_STEP,
)

# Coordinator is used to centralize the data updates
Expand All @@ -28,6 +31,18 @@
_LOGGER = logging.getLogger(__name__)


SETPOINT_STEP_ENTITY = {
"name": "Setpoint Step",
"key": "setpoint_step",
"unit": "",
"min": 0.01,
"max": 10.0,
"step": 0.01,
"default": DEFAULT_SETPOINT_STEP,
"entity_category": EntityCategory.CONFIG,
}


PID_NUMBER_ENTITIES = [
{
"name": "Kp",
Expand Down Expand Up @@ -116,6 +131,7 @@ async def async_setup_entry(
entities = [
ControlParameterNumber(hass, entry, desc) for desc in CONTROL_NUMBER_ENTITIES
]
entities.append(SetpointStepNumber(hass, entry, SETPOINT_STEP_ENTITY))
async_add_entities(entities)


Expand Down Expand Up @@ -162,14 +178,19 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None:
self._attr_icon = "mdi:ray-vertex"
self._attr_mode = "box"
self._attr_native_unit_of_measurement = desc["unit"]
self._attr_native_step = desc["step"]
self._attr_native_value = desc["default"]
self._attr_entity_category = desc["entity_category"]
self._key = desc["key"]

# Compute range limits based on key
# Compute range limits based on key.
opts = entry.options or {}
data = entry.data or {}

if self._key == "setpoint":
self._attr_native_step = opts.get(CONF_SETPOINT_STEP, DEFAULT_SETPOINT_STEP)
else:
self._attr_native_step = desc["step"]

input_range_min = opts.get(
CONF_INPUT_RANGE_MIN,
data.get(CONF_INPUT_RANGE_MIN, DEFAULT_INPUT_RANGE_MIN),
Expand Down Expand Up @@ -208,7 +229,6 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None:

self._attr_native_min_value = min_val
self._attr_native_max_value = max_val
self._attr_native_step = desc.get("step", 1.0)

# Initialize current value
if self._key == "setpoint":
Expand Down Expand Up @@ -243,3 +263,38 @@ def native_value(self) -> float:
async def async_set_native_value(self, value: float) -> None:
self._attr_native_value = value
self.async_write_ha_state()


class SetpointStepNumber(RestoreNumber):
"""Editable number entity for the setpoint step size."""

def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, desc: dict[str, Any]
) -> None:
BasePIDEntity.__init__(self, hass, entry, desc["key"], desc["name"])
RestoreNumber.__init__(self)

opts = entry.options or {}

self._attr_icon = "mdi:tune-vertical"
self._attr_mode = "box"
self._attr_native_unit_of_measurement = desc["unit"]
self._attr_native_min_value = desc["min"]
self._attr_native_max_value = desc["max"]
self._attr_native_step = desc["step"]
self._attr_native_value = opts.get(CONF_SETPOINT_STEP, desc["default"])
self._attr_entity_category = desc["entity_category"]

@property
def native_value(self) -> float:
return self._attr_native_value

async def async_set_native_value(self, value: float) -> None:
self._attr_native_value = value
updated_options = dict(self._entry.options)
updated_options[CONF_SETPOINT_STEP] = value
self.hass.config_entries.async_update_entry(
self._entry,
options=updated_options,
)
await self.hass.config_entries.async_reload(self._entry.entry_id)
3 changes: 2 additions & 1 deletion custom_components/simple_pid_controller/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"input_range_min": "Minimum Input Range",
"input_range_max": "Maximum Input Range",
"output_range_min": "Minimum Output Range",
"output_range_max": "Maximum Output Range"
"output_range_max": "Maximum Output Range",
"setpoint_step": "Setpoint Step"
}
}
}
Expand Down
22 changes: 13 additions & 9 deletions custom_components/simple_pid_controller/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,19 @@
"init": {
"title": "Edit PID Controller Options",
"description": "Modify the sensor entity and range used by the controller.",
"data": {
"sensor_entity_id": "Sensor Entity",
"input_range_min": "Minimum Input Range",
"input_range_max": "Maximum Input Range",
"output_range_min": "Minimum Output Range",
"output_range_max": "Maximum Output Range"
}
}
},
"data": {
"sensor_entity_id": "Sensor Entity",
"input_range_min": "Minimum Input Range",
"input_range_max": "Maximum Input Range",
"output_range_min": "Minimum Output Range",
"output_range_max": "Maximum Output Range",
"setpoint_step": "Setpoint Step"
},
"data_description": {
"setpoint_step": "Increment size for the setpoint picker."
}
}
},
"error": {
"range_min_max": "Minimum must be lower than maximum."
}
Expand Down
6 changes: 5 additions & 1 deletion custom_components/simple_pid_controller/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@
"input_range_min": "Minimum Input Bereik",
"input_range_max": "Maximum Input Bereik",
"output_range_min": "Minimum Output Bereik",
"output_range_max": "Maximum Output Bereik"
"output_range_max": "Maximum Output Bereik",
"setpoint_step": "Stappgrootte Setpoint"
},
"data_description": {
"setpoint_step": "Stapgrootte voor de setpoint-kiezer."
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResultType

from custom_components.simple_pid_controller.const import CONF_SETPOINT_STEP
from custom_components.simple_pid_controller.const import (
DOMAIN,
CONF_NAME,
Expand Down Expand Up @@ -142,6 +143,7 @@ def test_async_get_options_flow():
CONF_INPUT_RANGE_MAX: 10.0,
CONF_OUTPUT_RANGE_MIN: 1.0,
CONF_OUTPUT_RANGE_MAX: 10.0,
CONF_SETPOINT_STEP: 0.5,
},
None,
),
Expand All @@ -152,6 +154,7 @@ def test_async_get_options_flow():
CONF_INPUT_RANGE_MAX: 5.0,
CONF_OUTPUT_RANGE_MIN: 1.0,
CONF_OUTPUT_RANGE_MAX: 10.0,
CONF_SETPOINT_STEP: 0.5,
},
{"base": "input_range_min_max"},
),
Expand All @@ -162,6 +165,7 @@ def test_async_get_options_flow():
CONF_INPUT_RANGE_MAX: 10.0,
CONF_OUTPUT_RANGE_MIN: 10.0,
CONF_OUTPUT_RANGE_MAX: 5.0,
CONF_SETPOINT_STEP: 0.5,
},
{"base": "output_range_min_max"},
),
Expand Down
103 changes: 101 additions & 2 deletions tests/test_number.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import pytest
import logging
from types import SimpleNamespace

import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.simple_pid_controller.number import (
PID_NUMBER_ENTITIES,
CONTROL_NUMBER_ENTITIES,
PIDParameterNumber,
ControlParameterNumber,
SETPOINT_STEP_ENTITY,
SetpointStepNumber,
)
from custom_components.simple_pid_controller.const import (
CONF_NAME,
CONF_SENSOR_ENTITY_ID,
CONF_SETPOINT_STEP,
DEFAULT_SETPOINT_STEP,
DOMAIN,
DEFAULT_INPUT_RANGE_MIN,
DEFAULT_INPUT_RANGE_MAX,
DEFAULT_OUTPUT_RANGE_MIN,
Expand All @@ -19,7 +29,7 @@ async def test_number_platform(hass, config_entry):
"""Check that all Number entities from PID_NUMBER_ENTITIES are created."""

numbers = hass.states.async_entity_ids("number")
assert len(numbers) == len(PID_NUMBER_ENTITIES) + len(CONTROL_NUMBER_ENTITIES)
assert len(numbers) == len(PID_NUMBER_ENTITIES) + len(CONTROL_NUMBER_ENTITIES) + 1


@pytest.mark.usefixtures("setup_integration")
Expand Down Expand Up @@ -163,3 +173,92 @@ async def test_controlparameter_number_unexpected_key(
assert f"Unknown PID key '{invalid_key}'. Using default values:" in caplog.text
assert num._attr_native_min_value == expected_min
assert num._attr_native_max_value == expected_max


def test_setpoint_uses_configured_step(hass):
"""Test that the setpoint control uses the configured step option."""
setpoint_desc = next(
desc for desc in CONTROL_NUMBER_ENTITIES if desc["key"] == "setpoint"
)
entry = MockConfigEntry(
domain=DOMAIN,
entry_id="PID3",
title="Test PID Controller",
data={CONF_SENSOR_ENTITY_ID: "sensor.test_input", CONF_NAME: "PID3"},
options={CONF_SETPOINT_STEP: 0.5},
)
entry.runtime_data = SimpleNamespace(handle=SimpleNamespace(name="PID3"))

num = ControlParameterNumber(hass, entry, setpoint_desc)

assert num._attr_native_step == 0.5


def test_setpoint_defaults_to_default_step(hass):
"""Test that the setpoint control falls back to the default step."""
setpoint_desc = next(
desc for desc in CONTROL_NUMBER_ENTITIES if desc["key"] == "setpoint"
)
entry = MockConfigEntry(
domain=DOMAIN,
entry_id="PID4",
title="Test PID Controller",
data={CONF_SENSOR_ENTITY_ID: "sensor.test_input", CONF_NAME: "PID4"},
)
entry.runtime_data = SimpleNamespace(handle=SimpleNamespace(name="PID4"))

num = ControlParameterNumber(hass, entry, setpoint_desc)

assert num._attr_native_step == DEFAULT_SETPOINT_STEP


def test_setpoint_step_entity_uses_configured_value(hass):
"""Test that the setpoint step config entity reflects the saved option."""
entry = MockConfigEntry(
domain=DOMAIN,
entry_id="PID5",
title="Test PID Controller",
data={CONF_SENSOR_ENTITY_ID: "sensor.test_input", CONF_NAME: "PID5"},
options={CONF_SETPOINT_STEP: 0.25},
)
entry.runtime_data = SimpleNamespace(handle=SimpleNamespace(name="PID5"))

num = SetpointStepNumber(hass, entry, SETPOINT_STEP_ENTITY)

assert num._attr_native_min_value == 0.01
assert num._attr_native_max_value == 10.0
assert num._attr_native_step == 0.01
assert num.native_value == 0.25


async def test_setpoint_step_entity_updates_entry_options_and_reloads(
hass, monkeypatch
):
"""Test that changing setpoint step persists options and reloads the entry."""
entry = MockConfigEntry(
domain=DOMAIN,
entry_id="PID6",
title="Test PID Controller",
data={CONF_SENSOR_ENTITY_ID: "sensor.test_input", CONF_NAME: "PID6"},
)
entry.runtime_data = SimpleNamespace(handle=SimpleNamespace(name="PID6"))
num = SetpointStepNumber(hass, entry, SETPOINT_STEP_ENTITY)

update_calls = []
reload_calls = []

def fake_update_entry(target_entry, *, options):
update_calls.append((target_entry, options))

async def fake_reload(entry_id):
reload_calls.append(entry_id)
return True

monkeypatch.setattr(hass.config_entries, "async_update_entry", fake_update_entry)
monkeypatch.setattr(hass.config_entries, "async_reload", fake_reload)

await num.async_set_native_value(0.25)

assert num.native_value == 0.25
assert update_calls == [(entry, {CONF_SETPOINT_STEP: 0.25})]
assert reload_calls == [entry.entry_id]