Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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 .bumpversion.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "1.3.0"
current_version = "1.4.0"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
Expand Down
8 changes: 0 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
- [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)
- [More details and extended documentation](#extended-documentation)
- [Example PID Graph](#example-pid-graph)
- [Support & Development](#support--development)
Expand Down Expand Up @@ -144,13 +143,6 @@ 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**
Expand Down
20 changes: 19 additions & 1 deletion custom_components/simple_pid_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@

_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.NUMBER,
Platform.SWITCH,
Platform.SELECT,
]


@dataclass
Expand Down Expand Up @@ -89,6 +94,19 @@ def get_number(self, key: str) -> float | None:
)
return None

def get_select(self, key: str) -> str | None:
"""Return the current value of the select entity, or None."""
entity_id = self._get_entity_id("select", key)
if not entity_id:
return None
state = self.hass.states.get(entity_id)
_LOGGER.debug("get_select(%s) → %s = %s", key, entity_id, state and state.state)

if state and state.state not in ("unknown", "unavailable"):
return state.state # Selects geven strings terug, geen conversie nodig

return None

def get_switch(self, key: str) -> bool:
"""Return True/False of switch entity, default True if missing."""
entity_id = self._get_entity_id("switch", key)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/simple_pid_controller/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
"quality_scale": "silver",
"requirements": ["simple-pid==2.0.1"],
"ssdp": [],
"version": "1.3.0",
"version": "1.4.0",
"zeroconf": []
}
20 changes: 17 additions & 3 deletions custom_components/simple_pid_controller/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"unit": "",
"min": -10.0,
"max": 10.0,
"step": 0.01,
"step": 0.001,
"default": 1.0,
"entity_category": EntityCategory.CONFIG,
},
Expand All @@ -45,7 +45,7 @@
"unit": "",
"min": -10.0,
"max": 10.0,
"step": 0.01,
"step": 0.001,
"default": 0.1,
"entity_category": EntityCategory.CONFIG,
},
Expand All @@ -55,7 +55,7 @@
"unit": "",
"min": -10.0,
"max": 10.0,
"step": 0.01,
"step": 0.001,
"default": 0.05,
"entity_category": EntityCategory.CONFIG,
},
Expand Down Expand Up @@ -96,6 +96,14 @@
"default": 1,
"entity_category": EntityCategory.CONFIG,
},
{
"name": "Startup Value",
"key": "starting_output",
"unit": "",
"step": 1.0,
"default": 0.0,
"entity_category": EntityCategory.CONFIG,
},
]


Expand Down Expand Up @@ -181,6 +189,8 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None:

if self._key == "setpoint":
min_val, max_val = input_range_min, input_range_max
elif self._key == "starting_output":
min_val, max_val = output_range_min, output_range_max
elif self._key == "output_min":
min_val, max_val = output_range_min, output_range_max
elif self._key == "output_max":
Expand All @@ -205,6 +215,10 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None:
self._attr_native_value = input_range_min + (
input_range_max - input_range_min
) * float(desc["default"])
elif self._key == "starting_output":
self._attr_native_value = output_range_min + (
output_range_max - output_range_min
) * float(desc["default"])
elif self._key == "output_min":
self._attr_native_value = output_range_min
elif self._key == "output_max":
Expand Down
44 changes: 44 additions & 0 deletions custom_components/simple_pid_controller/select.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from homeassistant.components.select import SelectEntity
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.entity import EntityCategory

from .entity import BasePIDEntity

START_MODE_OPTIONS = [
"Zero start", # Simple and safe, but may cause jumps
"Last known value", # Continuous, smooth resumption
"Startup value", # User-defined default at startup
]


async def async_setup_entry(hass, entry, async_add_entities):
"""Set up the PID start mode select entity."""
coordinator = entry.runtime_data.coordinator
async_add_entities(
[PIDStartModeSelect(hass, entry, "start_mode", "PID Start Mode", coordinator)]
)


class PIDStartModeSelect(BasePIDEntity, SelectEntity, RestoreEntity):
"""Representation of the PID start mode selection."""

def __init__(self, hass, entry, key, name, coordinator):
super().__init__(hass, entry, key, name)
self._attr_options = START_MODE_OPTIONS
self._attr_current_option = START_MODE_OPTIONS[0]
self._attr_entity_category = EntityCategory.CONFIG
self.coordinator = coordinator # if needed later

async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
if option in self._attr_options:
self._attr_current_option = option
self.async_write_ha_state()

async def async_added_to_hass(self):
"""Restore previous state."""
await super().async_added_to_hass()
if (
last_state := await self.async_get_last_state()
) and last_state.state in self._attr_options:
self._attr_current_option = last_state.state
90 changes: 65 additions & 25 deletions custom_components/simple_pid_controller/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.restore_state import RestoreEntity

from datetime import timedelta
from simple_pid import PID
Expand All @@ -32,12 +33,14 @@ async def async_setup_entry(
) -> None:
"""Set up PID output and diagnostic sensors."""
handle: PIDDeviceHandle = entry.runtime_data.handle
handle.init_phase = True

# Init PID with default values
pid = PID(1.0, 0.1, 0.05, setpoint=50)
pid.sample_time = 10.0
pid.output_limits = (-10.0, 10.0)
handle.pid = PID(1.0, 0.1, 0.05, setpoint=50, sample_time=None)

handle.pid.output_limits = (-10.0, 10.0)
handle.last_contributions = (0, 0, 0, 0)
handle.last_known_output = None

async def update_pid():
"""Update the PID output using current sensor and parameter values."""
Expand All @@ -50,6 +53,8 @@ async def update_pid():
ki = handle.get_number("ki")
kd = handle.get_number("kd")
setpoint = handle.get_number("setpoint")
starting_output = handle.get_number("starting_output")
start_mode = handle.get_select("start_mode")
sample_time = handle.get_number("sample_time")
out_min = handle.get_number("output_min")
out_max = handle.get_number("output_max")
Expand All @@ -58,48 +63,66 @@ async def update_pid():
windup_protection = handle.get_switch("windup_protection")

# adapt PID settings
pid.tunings = (kp, ki, kd)
pid.setpoint = setpoint
pid.sample_time = sample_time
handle.pid.tunings = (kp, ki, kd)
handle.pid.setpoint = setpoint

if windup_protection:
pid.output_limits = (out_min, out_max)
handle.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
handle.pid.output_limits = (None, None)

_LOGGER.debug("Start mode = %s (type: %s)", start_mode, type(start_mode))
if (handle.init_phase and auto_mode) or (
not handle.pid.auto_mode and auto_mode
):
handle.init_phase = False
if start_mode == "Zero start":
handle.pid.set_auto_mode(True, 0)
elif start_mode == "Last known value":
handle.pid.set_auto_mode(True, handle.last_known_output)
elif start_mode == "Startup value":
handle.pid.set_auto_mode(True, starting_output)
else:
handle.pid.set_auto_mode(True)
else:
handle.pid.auto_mode = auto_mode
handle.init_phase = False

handle.pid.proportional_on_measurement = p_on_m

output = pid(input_value)
output = handle.pid(input_value)

# save last know output
handle.last_known_output = output

# save last I contribution
last_i = handle.last_contributions[1]

# save all latest contributions
handle.last_contributions = (
pid.components[0],
pid.components[1],
pid.components[2],
pid.components[1] - last_i,
handle.pid.components[0],
handle.pid.components[1],
handle.pid.components[2],
handle.pid.components[1] - last_i,
)

_LOGGER.debug(
"PID input=%s setpoint=%s kp=%s ki=%s kd=%s => output=%s [P=%s, I=%s, D=%s, dI=%s]",
input_value,
pid.setpoint,
pid.Kp,
pid.Ki,
pid.Kd,
handle.pid.setpoint,
handle.pid.Kp,
handle.pid.Ki,
handle.pid.Kd,
output,
handle.last_contributions[0],
handle.last_contributions[1],
handle.last_contributions[2],
handle.last_contributions[3],
)

if coordinator.update_interval.total_seconds() != pid.sample_time:
_LOGGER.debug(
"Updating coordinator interval to %.2f seconds", pid.sample_time
)
coordinator.update_interval = timedelta(seconds=pid.sample_time)
if coordinator.update_interval.total_seconds() != sample_time:
_LOGGER.debug("Updating coordinator interval to %.2f seconds", sample_time)
coordinator.update_interval = timedelta(seconds=sample_time)

return output

Expand Down Expand Up @@ -163,8 +186,15 @@ def _listener(event):
"state_changed", make_listener(f"switch.{entry.entry_id}_{key}")
)

for key in ["start_mode"]:
hass.bus.async_listen(
"state_changed", make_listener(f"select.{entry.entry_id}_{key}")
)


class PIDOutputSensor(CoordinatorEntity[PIDDataCoordinator], SensorEntity):
class PIDOutputSensor(
CoordinatorEntity[PIDDataCoordinator], RestoreEntity, SensorEntity
):
"""Sensor representing the PID output."""

def __init__(
Expand All @@ -179,6 +209,16 @@ def __init__(

self._attr_native_unit_of_measurement = "%"
self._attr_state_class = SensorStateClass.MEASUREMENT
self.handle = entry.runtime_data.handle

async def async_added_to_hass(self):
await super().async_added_to_hass()
if (state := await self.async_get_last_state()) is not None:
try:
value = float(state.state)
self.handle.last_known_output = value
except (ValueError, TypeError):
self.handle.last_known_output = 0.0

@property
def native_value(self) -> float | None:
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
asyncio
pytest
pytest==8.3.5
pytest-cov
pytest-homeassistant-custom-component
simple-pid==2.0.1
45 changes: 45 additions & 0 deletions tests/test_device_handle.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,48 @@ async def test_get_switch_returns_true_when_no_entity_configured(hass, config_en
# Force no entity_id
handle._get_entity_id = lambda platform, key: None
assert handle.get_switch("any_key") is True


@pytest.mark.parametrize(
"state_value, expected",
[
("Zero start", "Zero start"), # valid select option
("unknown", None), # invalid
("unavailable", None), # invalid
(None, None), # no state
],
)
def test_get_select_various_states(
monkeypatch, hass, config_entry, state_value, expected
):
"""Test PIDDeviceHandle.get_select behavior for valid and invalid entity states."""

fake_eid = "select.pid_entry_start_mode"

# Inject fake state
if state_value is not None:
hass.states.async_set(fake_eid, state_value)

# Patch entity_registry.async_get(hass) → returns dummy registry object
class DummyRegistry:
def async_get_entity_id(self, platform, domain, unique_id):
if domain == DOMAIN and unique_id.endswith("start_mode"):
return fake_eid
return None

monkeypatch.setattr(er, "async_get", lambda hass: DummyRegistry())

handle = PIDDeviceHandle(hass, config_entry)
result = handle.get_select("start_mode")

assert result == expected


def test_get_select_no_entity(monkeypatch, hass, config_entry):
"""If _get_entity_id returns None, get_select should return None."""
# Patch de registry zo dat er geen entity_id wordt gevonden
monkeypatch.setattr(er, "async_get", lambda hass_: DummyRegistry(None))

handle = PIDDeviceHandle(hass, config_entry)
# Key mag willekeurig zijn, er is immers geen entity
assert handle.get_select("nonexistent_key") is None
Loading