Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a0d6450
ci: bump actions/checkout from 4 to 5
dependabot[bot] Aug 18, 2025
405c59d
Merge pull request #108 from bvweerd/dependabot/github_actions/action…
bvweerd Aug 19, 2025
99d0b55
ci: bump actions/setup-python from 5 to 6
dependabot[bot] Sep 8, 2025
21632d8
Merge pull request #109 from bvweerd/dependabot/github_actions/action…
bvweerd Sep 13, 2025
25a1bd8
Add extended diagnostics history
bvweerd Sep 28, 2025
0017021
Add diagnostics sensor for actual sample time
bvweerd Sep 28, 2025
a669c80
Merge pull request #114 from bvweerd/add-diagnostics-sensor-for-sampl…
bvweerd Sep 29, 2025
b2e70b3
Merge branch 'dev' into add-detailed-input-sensor-info-to-diagnostics
bvweerd Sep 29, 2025
897872e
Merge pull request #113 from bvweerd/add-detailed-input-sensor-info-t…
bvweerd Sep 29, 2025
2d680e4
Create stale.yml
bvweerd Sep 30, 2025
da50a5f
Track sample time history in diagnostics
bvweerd Oct 2, 2025
f275a99
Merge da50a5f83f24407da7cf0cb4979965a8c30445b1 into 2d680e4b4c0c1f0b8…
bvweerd Oct 2, 2025
b122abd
chore: apply pre-commit fixes
github-actions[bot] Oct 2, 2025
3e431ad
Merge pull request #115 from bvweerd/remove-pid-values-from-diagnostics
bvweerd Oct 2, 2025
ee6c39c
Clarify integral behaviour in README
bvweerd Oct 4, 2025
0ee24cf
Merge pull request #117 from bvweerd/update-readme-to-prevent-common-…
bvweerd Oct 4, 2025
8477618
ci: bump actions/stale from 9 to 10
dependabot[bot] Oct 6, 2025
8d9fe4a
Merge pull request #118 from bvweerd/dependabot/github_actions/action…
bvweerd Oct 6, 2025
fd53110
Fix setpoint input validation for decimal values
claude Nov 17, 2025
37be142
Merge pull request #121 from bvweerd/claude/fix-setpoint-decimals-018…
bvweerd Nov 17, 2025
eaa412f
Merge branch 'main' into dev
bvweerd Nov 17, 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
2 changes: 1 addition & 1 deletion .github/workflows/hassfest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Run hassfest
uses: home-assistant/actions/hassfest@master
4 changes: 2 additions & 2 deletions .github/workflows/precommit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: 'pip' # caching pip dependencies
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: 'pip' # caching pip dependencies
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release-versioning.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: main

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

Expand All @@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Create GitHub Release from tag
uses: softprops/action-gh-release@v2
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- name: Get version
id: version
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Mark & close stale issues/PRs

on:
schedule:
# Dagelijks om 02:17 UTC
- cron: "17 2 * * *"
workflow_dispatch:

permissions:
issues: write
pull-requests: write

jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
# ALGEMEEN
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 200
days-before-stale: 7 # Issues worden 'stale' na 60 dagen inactiviteit
days-before-close: 14 # en gesloten 14 dagen later
stale-issue-label: "stale"
stale-pr-label: "stale"
exempt-issue-labels: "pinned,security,backlog,never-stale"
exempt-pr-labels: "work-in-progress,never-stale"
exempt-all-milestones: true # Items met milestone overslaan
exempt-assignees: "" # Vul evt. gebruikers in, kommagescheiden
remove-stale-when-updated: true
ignore-updates: false # Als iemand reageert of labelt, wordt 'stale' verwijderd
ascending: false

# ISSUES
stale-issue-message: |
💤 Deze issue heeft 60 dagen geen activiteit gehad en is gemarkeerd als **stale**.
Als er binnen 14 dagen geen nieuwe activiteit is, wordt deze automatisch gesloten.
Voeg svp een update toe of label met `never-stale` om dit te voorkomen.
close-issue-message: |
🔒 Deze issue is automatisch gesloten wegens inactiviteit.
Als dit nog steeds actueel is, heropen of maak een nieuwe issue met de laatste context. Bedankt!

# PULL REQUESTS
days-before-pr-stale: 30 # PRs iets sneller stale
days-before-pr-close: 10
stale-pr-message: |
💤 Deze pull request is 30 dagen inactief en is gemarkeerd als **stale**.
Reageer of push nieuwe commits binnen 10 dagen om sluiten te voorkomen.
Label met `never-stale` om uit te sluiten.
close-pr-message: |
🔒 Deze pull request is automatisch gesloten wegens inactiviteit.
Heropen gerust wanneer je verder wilt gaan.

# FILTERS (optioneel; laat leeg om alles te laten meedraaien)
only-labels: "" # Bv. "triage" om alleen issues met dat label te targeten
any-of-labels: "" # Bv. "question,help wanted"
exempt-draft-pr: true # Draft PRs overslaan
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ A PID controller continuously corrects the difference between a **setpoint** and

</details>

### Common pitfalls with the I term

Several reported issues stem from the interaction between the **integral term** and the controller's configured limits. Keep the following points in mind when tuning:

- **Integral sensor shows the clamped output** – The integration exposes a diagnostic entity for the I contribution. When `Output Min`/`Output Max` are set, the value you see in the UI is the clamped contribution after limits are applied. This means that with `Ki = 0` the integral value will simply remain at the boundary (e.g. `Output Min = 3` results in `I = 3`). This is expected behaviour and not a sign that the integral is "stuck".
- **Choose a suitable start value** – When the controller is initialised it sets the integral term to the configured start value, but still clamps it within your `Output Min`/`Output Max` range. If you want the controller to begin from `0`, select the **PID Start Value** option `zero_start` (or call the `simple_pid_controller.set_output` service with the `preset` parameter) and temporarily widen the output range if needed. Otherwise the integral will be forced to the lowest allowed value.
- **Ki must be non-zero to change the integral** – Setting `Ki = 0` effectively freezes the integral term. Use a small but non-zero value if you need the controller to move away from the clamp over time. You can combine this with **Windup Protection** (toggle entity) to prevent large overshoots once the setpoint is reached.

If your process requires a non-zero minimum output (for example, EV charging currents that must stay above 6 A), start your tuning with:

1. `PID Start Value = zero_start` so the controller can compute from a neutral baseline.
2. A modest proportional gain (`Kp`) that does not immediately drive the output below the minimum.
3. A small integral gain (`Ki`) to let the controller move away from the clamped value without causing overshoot.

These steps help avoid the "integral stuck at minimum" effect while keeping the controller within the bounds your hardware requires.

---

### How it works in practice
Expand Down
9 changes: 9 additions & 0 deletions custom_components/simple_pid_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import entity_registry as er
from collections import deque
from dataclasses import dataclass
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
Expand Down Expand Up @@ -86,6 +87,14 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
self.last_contributions = (None, None, None) # (P, I, D)
self.last_known_output = None

self.input_history: deque[float] = deque(maxlen=10)
self.output_history: deque[float] = deque(maxlen=10)
self.pid_parameter_history: deque[dict[str, float | None]] = deque(maxlen=10)
self.pid_contribution_history: deque[dict[str, float | None]] = deque(maxlen=10)
self.sample_time_history: deque[float | None] = deque(maxlen=10)
self.last_update_timestamp: float | None = None
self.last_measured_sample_time: float | None = None

def _get_entity_id(self, platform: str, key: str) -> str | None:
"""Lookup the real entity_id in the registry by unique_id == '<entry_id>_<key>'."""
registry = er.async_get(self.hass)
Expand Down
18 changes: 18 additions & 0 deletions custom_components/simple_pid_controller/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
handle = entry.runtime_data.handle

sensor_state = hass.states.get(handle.sensor_entity_id)
input_sensor_info: dict[str, Any] | None = None
if sensor_state is not None:
input_sensor_info = {
"entity_id": sensor_state.entity_id,
"state": sensor_state.state,
"attributes": dict(sensor_state.attributes),
"last_changed": sensor_state.last_changed.isoformat(),
"last_updated": sensor_state.last_updated.isoformat(),
}

return {
"entry_data": entry.as_dict(),
"data": {
Expand All @@ -22,5 +33,12 @@ async def async_get_config_entry_diagnostics(
"input_range_max": handle.input_range_max,
"output_range_min": handle.output_range_min,
"output_range_max": handle.output_range_max,
"input_sensor": input_sensor_info,
"history": {
"input": list(handle.input_history),
"output": list(handle.output_history),
"pid_contributions": list(handle.pid_contribution_history),
"sample_time": list(handle.sample_time_history),
},
},
}
2 changes: 1 addition & 1 deletion custom_components/simple_pid_controller/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"name": "Setpoint",
"key": "setpoint",
"unit": "",
"step": 1.0,
"step": 0.01,
"default": 0.5,
"entity_category": None,
},
Expand Down
62 changes: 62 additions & 0 deletions custom_components/simple_pid_controller/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from homeassistant.helpers.restore_state import RestoreEntity

from datetime import timedelta
from time import perf_counter
from simple_pid import PID
from typing import Any

Expand Down Expand Up @@ -47,6 +48,8 @@ async def update_pid():
if input_value is None:
raise ValueError("Input sensor not available")

handle.input_history.append(input_value)

# Read parameters from UI
kp = handle.get_number("kp")
ki = handle.get_number("ki")
Expand All @@ -65,6 +68,15 @@ async def update_pid():
handle.pid.tunings = (kp, ki, kd)
handle.pid.setpoint = setpoint

handle.pid_parameter_history.append(
{
"kp": kp,
"ki": ki,
"kd": kd,
"setpoint": setpoint,
}
)

if windup_protection:
handle.pid.output_limits = (out_min, out_max)
else:
Expand All @@ -85,10 +97,20 @@ async def update_pid():

handle.pid.proportional_on_measurement = p_on_m

now = perf_counter()
if handle.last_update_timestamp is None:
handle.last_measured_sample_time = None
else:
handle.last_measured_sample_time = now - handle.last_update_timestamp
handle.last_update_timestamp = now

handle.sample_time_history.append(handle.last_measured_sample_time)

output = handle.pid(input_value)

# save last know output
handle.last_known_output = output
handle.output_history.append(output)

# save last I contribution
last_i = handle.last_contributions[1]
Expand All @@ -101,6 +123,15 @@ async def update_pid():
handle.pid.components[1] - last_i,
)

handle.pid_contribution_history.append(
{
"p": handle.last_contributions[0],
"i": handle.last_contributions[1],
"d": handle.last_contributions[2],
"i_delta": handle.last_contributions[3],
}
)

_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,
Expand Down Expand Up @@ -151,6 +182,9 @@ async def start_refresh(_: Any) -> None:
),
PIDContributionSensor(hass, entry, "error", "Error", coordinator),
PIDContributionSensor(hass, entry, "pid_i_delta", "I delta", coordinator),
PIDSampleTimeSensor(
hass, entry, "actual_sample_time", "Actual Sample Time", coordinator
),
]
)

Expand Down Expand Up @@ -263,3 +297,31 @@ def native_value(self):
"pid_i_delta": contributions[3],
}.get(self._key)
return round(value, 3) if value is not None else None


class PIDSampleTimeSensor(CoordinatorEntity[PIDDataCoordinator], SensorEntity):
"""Sensor exposing the measured sample time between PID updates."""

def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
key: str,
name: str,
coordinator: PIDDataCoordinator,
) -> None:
super().__init__(coordinator)

BasePIDEntity.__init__(self, hass, entry, key, name)

self._attr_entity_category = EntityCategory.DIAGNOSTIC
self._attr_entity_registry_enabled_default = False
self._attr_state_class = SensorStateClass.MEASUREMENT
self._attr_native_unit_of_measurement = "s"

@property
def native_value(self) -> float | None:
sample_time = self._handle.last_measured_sample_time
if sample_time is None:
return None
return round(sample_time, 3)
Loading