Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
68c78a1
initial scaling
May 14, 2025
e44769b
Linting fixes:
bvweerd May 14, 2025
e514d64
Fixed Testing errors
bvweerd May 15, 2025
928891a
Fixed Testing errors
bvweerd May 15, 2025
9a5940e
modified: tests/test_number.py
bvweerd May 15, 2025
56a0014
ci: bump actions/setup-python from 4 to 5
dependabot[bot] May 15, 2025
d92d1bf
ci: bump softprops/action-gh-release from 1 to 2
dependabot[bot] May 15, 2025
3947d90
ci: bump actions/checkout from 3 to 4
dependabot[bot] May 15, 2025
78b02e8
Merge pull request #11 from bvweerd/dependabot/github_actions/actions…
bvweerd May 15, 2025
657d503
Merge pull request #10 from bvweerd/dependabot/github_actions/softpro…
bvweerd May 15, 2025
fa30109
Merge pull request #9 from bvweerd/dependabot/github_actions/actions/…
bvweerd May 15, 2025
af78ba7
Updated tests with PID controller:
bvweerd May 16, 2025
7ec3032
modified: tests/conftest.py
bvweerd May 17, 2025
549c9b3
Merge pull request #12 from bvweerd/increase-test-coverage
bvweerd May 17, 2025
306ee14
Changed default name to CONF_NAME
bvweerd May 17, 2025
ecc0a48
Revert "Changed default name to CONF_NAME"
bvweerd May 17, 2025
62fcb92
Fix device name
bvweerd May 18, 2025
f526259
fix default name
bvweerd May 18, 2025
39a0de2
modified: custom_components/simple_pid_controller/config_flow.py
bvweerd May 18, 2025
b07d4fb
Merge branch 'add-scaling' into resync-scaling-branch-with-dev
bvweerd May 18, 2025
9238e5c
Merge pull request #13 from bvweerd/resync-scaling-branch-with-dev
bvweerd May 18, 2025
00446e5
fix naming
bvweerd May 18, 2025
635f907
add min/max values
bvweerd May 18, 2025
35d4ac1
modified: custom_components/simple_pid_controller/number.py
bvweerd May 19, 2025
0a38ddc
modified: custom_components/simple_pid_controller/config_flow.py
bvweerd May 19, 2025
d5d7da4
modified: custom_components/simple_pid_controller/__init__.py
bvweerd May 19, 2025
db543c7
modified: README.md
bvweerd May 19, 2025
8fd4175
Changes to be committed:
bvweerd May 19, 2025
be657c3
modified: custom_components/simple_pid_controller/config_flow.py
bvweerd May 19, 2025
87da546
Changes to be committed:
bvweerd May 19, 2025
4dc85e6
Merge pull request #15 from bvweerd/add-scaling
bvweerd May 19, 2025
0c36e79
new file: custom_components/simple_pid_controller/entity.py
bvweerd May 19, 2025
4acc54e
Changes to be committed:
bvweerd May 19, 2025
495db25
modified: custom_components/simple_pid_controller/quality_scale.yaml
bvweerd May 19, 2025
91e9e9f
Merge pull request #16 from bvweerd/centralize-common-code
bvweerd May 19, 2025
1fb873c
Create en.json
bvweerd May 19, 2025
9b7f367
Create en.json
bvweerd May 19, 2025
2a29387
Revert "Create en.json"
bvweerd May 19, 2025
7be44af
Update translation
bvweerd May 19, 2025
d32fe63
Update en.json
bvweerd May 19, 2025
3c0be06
new file: custom_components/simple_pid_controller/translations/en.…
bvweerd May 20, 2025
9c7f187
Update en.json
May 20, 2025
a62cf6e
Update en.json
May 20, 2025
76a2fe7
Update en.json
May 20, 2025
4cc0dac
Update en.json
May 20, 2025
e5f7a01
Add Dutch
May 20, 2025
8f5b069
Add translations EN and NL
bvweerd May 20, 2025
291e086
Changes to be committed:
bvweerd May 20, 2025
500df28
Merge branch 'dev' into code-optimizations
bvweerd May 20, 2025
d28c383
Merge pull request #18 from bvweerd/code-optimizations
bvweerd May 20, 2025
eb96e51
Add validation in config flow
May 21, 2025
e4c97d0
Added tests including edge cases for config_flow.
bvweerd May 21, 2025
52100bd
added tests to match 100% test coverage
bvweerd May 21, 2025
e9a0552
Merge pull request #19 from bvweerd/option-flow-validation
bvweerd May 21, 2025
010437d
Migrate to runtime_data
bvweerd May 21, 2025
f285f13
migrate to runtime_data
bvweerd May 21, 2025
dde09ca
Update __init__.py
bvweerd May 21, 2025
e71075f
Update __init__.py
bvweerd May 21, 2025
e446e93
Changed all to runtime_data
bvweerd May 22, 2025
052a2e8
fix config flow setting of min/max during setup
bvweerd May 22, 2025
2ffc591
fix setpoint to 50% of initial value
bvweerd May 22, 2025
a6e5bcd
Fixed config flow tests
bvweerd May 22, 2025
62a8007
Merge pull request #20 from bvweerd/migrate-to-runtime-data
bvweerd May 22, 2025
c93489a
Update quality_scale.yaml
bvweerd May 22, 2025
c86717b
added data update explanation
bvweerd May 22, 2025
52c1615
update strings
bvweerd May 23, 2025
9981511
add diagnostics
bvweerd May 23, 2025
e97714d
Verification of diagnostics, update quality scale
bvweerd May 23, 2025
8d1b4a9
linting error fix
bvweerd May 23, 2025
d21131f
fix linting
bvweerd May 23, 2025
9c382e1
Merge pull request #21 from bvweerd/beautify-code
bvweerd May 23, 2025
7c53416
Merge pull request #22 from bvweerd/add-diagnostics
bvweerd May 23, 2025
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
8 changes: 4 additions & 4 deletions .github/workflows/release-versioning.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.x'

Expand All @@ -47,10 +47,10 @@ 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
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name }}
env:
Expand Down
74 changes: 61 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

---

Expand All @@ -9,13 +9,17 @@
- [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)
- [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)


---

Expand All @@ -40,6 +44,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**.

Expand All @@ -63,22 +69,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**
Expand All @@ -87,6 +86,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, select the integration in **Settings > Devices & Services**, click **Options**, adjust **Range Min** and **Range Max**, and save.

---

## 📊 Entities Overview
Expand All @@ -101,6 +103,7 @@
| 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 |

---

Expand Down Expand Up @@ -138,6 +141,45 @@ A PID controller continuously corrects the difference between a **setpoint** and

</details>

---

## 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
Expand All @@ -153,3 +195,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.


49 changes: 41 additions & 8 deletions custom_components/simple_pid_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,43 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er

from .const import DOMAIN, CONF_NAME, CONF_SENSOR_ENTITY_ID
from dataclasses import dataclass
from .coordinator import PIDDataCoordinator

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__)

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


@dataclass
class MyData:
handle: PIDDeviceHandle
coordinator: PIDDataCoordinator = None


class PIDDeviceHandle:
"""Shared device handle for a PID controller config entry."""

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)
)
Expand Down Expand Up @@ -72,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

Expand All @@ -89,14 +111,25 @@ 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 = MyData(handle=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


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):
# reset runtime_data zodat tests slagen
entry.runtime_data = None
return unload_ok


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)
103 changes: 77 additions & 26 deletions custom_components/simple_pid_controller/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@
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,
CONF_RANGE_MIN,
CONF_RANGE_MAX,
DEFAULT_RANGE_MIN,
DEFAULT_RANGE_MAX,
)

_LOGGER = logging.getLogger(__name__)

Expand All @@ -32,61 +41,103 @@ 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
) -> 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={
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],
},
)

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_NAME, default=self.hass.config.location_name
): str,
vol.Required(CONF_SENSOR_ENTITY_ID): selector(
{"entity": {"domain": "sensor"}}
),
}
),
)
return self.async_show_form(step_id="user", data_schema=schema)


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."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
"""Manage the options form and save user input."""

# Pre-fill form with existing options or sensible defaults
current_sensor = self.config_entry.options.get(
CONF_SENSOR_ENTITY_ID,
self.config_entry.data.get(CONF_SENSOR_ENTITY_ID, ""),
)
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)

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_min,
): vol.Coerce(float),
vol.Required(
CONF_RANGE_MAX,
default=current_max,
): vol.Coerce(float),
}
)

# 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)
Loading
Loading