Skip to content
Merged
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/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest

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

- name: Get version
id: version
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- [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]{#more-details-and-extended-documentation)
- [Example PID Graph](#example-pid-graph)
- [Support & Development](#support--development)
- [Service Actions](#service-actions)
Expand Down Expand Up @@ -180,6 +181,14 @@ This integration recalculates the PID output at a fixed, user-configurable inter
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.```


---

## 📚 More details and extended documentation

The integration is based on simple-pid [https://pypi.org/project/simple-pid/](https://pypi.org/project/simple-pid/)

Read the user guide here: [https://simple-pid.readthedocs.io/en/latest/user_guide.html#user-guide](https://simple-pid.readthedocs.io/en/latest/user_guide.html#user-guide)

---

## 📈 Example PID Graph
Expand Down
30 changes: 22 additions & 8 deletions custom_components/simple_pid_controller/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
DOMAIN,
CONF_NAME,
CONF_SENSOR_ENTITY_ID,
CONF_RANGE_MIN,
CONF_RANGE_MAX,
DEFAULT_RANGE_MIN,
DEFAULT_RANGE_MAX,
CONF_INPUT_RANGE_MIN,
CONF_INPUT_RANGE_MAX,
CONF_OUTPUT_RANGE_MIN,
CONF_OUTPUT_RANGE_MAX,
DEFAULT_INPUT_RANGE_MIN,
DEFAULT_INPUT_RANGE_MAX,
DEFAULT_OUTPUT_RANGE_MIN,
DEFAULT_OUTPUT_RANGE_MAX,
)

_LOGGER = logging.getLogger(__name__)
Expand All @@ -39,11 +43,21 @@ 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.input_range_min = entry.options.get(
CONF_INPUT_RANGE_MIN,
entry.data.get(CONF_INPUT_RANGE_MIN, DEFAULT_INPUT_RANGE_MIN),
)
self.range_max = entry.options.get(
CONF_RANGE_MAX, entry.data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX)
self.input_range_max = entry.options.get(
CONF_INPUT_RANGE_MAX,
entry.data.get(CONF_INPUT_RANGE_MAX, DEFAULT_INPUT_RANGE_MAX),
)
self.output_range_min = entry.options.get(
CONF_OUTPUT_RANGE_MIN,
entry.data.get(CONF_OUTPUT_RANGE_MIN, DEFAULT_OUTPUT_RANGE_MIN),
)
self.output_range_max = entry.options.get(
CONF_OUTPUT_RANGE_MAX,
entry.data.get(CONF_OUTPUT_RANGE_MAX, DEFAULT_OUTPUT_RANGE_MAX),
)
self.sensor_entity_id = entry.options.get(
CONF_SENSOR_ENTITY_ID, entry.data.get(CONF_SENSOR_ENTITY_ID)
Expand Down
116 changes: 90 additions & 26 deletions custom_components/simple_pid_controller/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@
CONF_NAME,
DEFAULT_NAME,
CONF_SENSOR_ENTITY_ID,
CONF_RANGE_MIN,
CONF_RANGE_MAX,
DEFAULT_RANGE_MIN,
DEFAULT_RANGE_MAX,
CONF_INPUT_RANGE_MIN,
CONF_INPUT_RANGE_MAX,
CONF_OUTPUT_RANGE_MIN,
CONF_OUTPUT_RANGE_MAX,
DEFAULT_INPUT_RANGE_MIN,
DEFAULT_INPUT_RANGE_MAX,
DEFAULT_OUTPUT_RANGE_MIN,
DEFAULT_OUTPUT_RANGE_MAX,
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -54,33 +58,59 @@ 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_INPUT_RANGE_MIN, default=DEFAULT_INPUT_RANGE_MIN
): vol.Coerce(float),
vol.Optional(
CONF_INPUT_RANGE_MAX, default=DEFAULT_INPUT_RANGE_MAX
): vol.Coerce(float),
vol.Optional(
CONF_OUTPUT_RANGE_MIN, default=DEFAULT_OUTPUT_RANGE_MIN
): vol.Coerce(float),
vol.Optional(
CONF_OUTPUT_RANGE_MAX, default=DEFAULT_OUTPUT_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:
input_min_val = user_input.get(CONF_INPUT_RANGE_MIN)
input_max_val = user_input.get(CONF_INPUT_RANGE_MAX)
if (
input_min_val is not None
and input_max_val is not None
and input_min_val >= input_max_val
):
return self.async_show_form(
step_id="user",
data_schema=schema,
errors={"base": "input_range_min_max"},
)
output_min_val = user_input.get(CONF_OUTPUT_RANGE_MIN)
output_max_val = user_input.get(CONF_OUTPUT_RANGE_MAX)
if (
output_min_val is not None
and output_max_val is not None
and output_min_val >= output_max_val
):
return self.async_show_form(
step_id="user", data_schema=schema, errors={"base": "range_min_max"}
step_id="user",
data_schema=schema,
errors={"base": "output_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],
CONF_INPUT_RANGE_MIN: user_input[CONF_INPUT_RANGE_MIN],
CONF_INPUT_RANGE_MAX: user_input[CONF_INPUT_RANGE_MAX],
CONF_OUTPUT_RANGE_MIN: user_input[CONF_OUTPUT_RANGE_MIN],
CONF_OUTPUT_RANGE_MAX: user_input[CONF_OUTPUT_RANGE_MAX],
},
)

Expand All @@ -103,8 +133,18 @@ async def async_step_init(
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)
current_input_min = self.config_entry.options.get(
CONF_INPUT_RANGE_MIN, DEFAULT_INPUT_RANGE_MIN
)
current_input_max = self.config_entry.options.get(
CONF_INPUT_RANGE_MAX, DEFAULT_INPUT_RANGE_MAX
)
current_output_min = self.config_entry.options.get(
CONF_OUTPUT_RANGE_MIN, DEFAULT_OUTPUT_RANGE_MIN
)
current_output_max = self.config_entry.options.get(
CONF_OUTPUT_RANGE_MAX, DEFAULT_OUTPUT_RANGE_MAX
)

options_schema = vol.Schema(
{
Expand All @@ -113,26 +153,50 @@ async def async_step_init(
default=current_sensor,
): selector({"entity": {"domain": "sensor"}}),
vol.Required(
CONF_RANGE_MIN,
default=current_min,
CONF_INPUT_RANGE_MIN,
default=current_input_min,
): vol.Coerce(float),
vol.Required(
CONF_INPUT_RANGE_MAX,
default=current_input_max,
): vol.Coerce(float),
vol.Required(
CONF_OUTPUT_RANGE_MIN,
default=current_output_min,
): vol.Coerce(float),
vol.Required(
CONF_RANGE_MAX,
default=current_max,
CONF_OUTPUT_RANGE_MAX,
default=current_output_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:
input_min_val = user_input.get(CONF_INPUT_RANGE_MIN)
input_max_val = user_input.get(CONF_INPUT_RANGE_MAX)
if (
input_min_val is not None
and input_max_val is not None
and input_min_val >= input_max_val
):
return self.async_show_form(
step_id="init",
data_schema=options_schema,
errors={"base": "input_range_min_max"},
)
output_min_val = user_input.get(CONF_OUTPUT_RANGE_MIN)
output_max_val = user_input.get(CONF_OUTPUT_RANGE_MAX)
if (
output_min_val is not None
and output_max_val is not None
and output_min_val >= output_max_val
):
return self.async_show_form(
step_id="init",
data_schema=options_schema,
errors={"base": "range_min_max"},
errors={"base": "output_range_min_max"},
)

return self.async_create_entry(
Expand Down
12 changes: 8 additions & 4 deletions custom_components/simple_pid_controller/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@

CONF_SENSOR_ENTITY_ID = "sensor_entity_id"

CONF_RANGE_MIN = "range_min"
CONF_RANGE_MAX = "range_max"
CONF_INPUT_RANGE_MIN = "input_range_min"
CONF_INPUT_RANGE_MAX = "input_range_max"
CONF_OUTPUT_RANGE_MIN = "output_range_min"
CONF_OUTPUT_RANGE_MAX = "output_range_max"

DEFAULT_RANGE_MIN = 0.0
DEFAULT_RANGE_MAX = 100.0
DEFAULT_INPUT_RANGE_MIN = 0.0
DEFAULT_INPUT_RANGE_MAX = 100.0
DEFAULT_OUTPUT_RANGE_MIN = 0.0
DEFAULT_OUTPUT_RANGE_MAX = 100.0
6 changes: 4 additions & 2 deletions custom_components/simple_pid_controller/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ async def async_get_config_entry_diagnostics(
"data": {
"name": handle.name,
"sensor_entity_id": handle.sensor_entity_id,
"range_min": handle.range_min,
"range_max": handle.range_max,
"input_range_min": handle.input_range_min,
"input_range_max": handle.input_range_max,
"output_range_min": handle.output_range_min,
"output_range_max": handle.output_range_max,
},
}
63 changes: 42 additions & 21 deletions custom_components/simple_pid_controller/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@

from .entity import BasePIDEntity
from .const import (
CONF_RANGE_MIN,
CONF_RANGE_MAX,
DEFAULT_RANGE_MIN,
DEFAULT_RANGE_MAX,
CONF_INPUT_RANGE_MIN,
CONF_INPUT_RANGE_MAX,
CONF_OUTPUT_RANGE_MIN,
CONF_OUTPUT_RANGE_MAX,
DEFAULT_INPUT_RANGE_MIN,
DEFAULT_INPUT_RANGE_MAX,
DEFAULT_OUTPUT_RANGE_MIN,
DEFAULT_OUTPUT_RANGE_MAX,
)

# Coordinator is used to centralize the data updates
Expand All @@ -29,7 +33,7 @@
"name": "Kp",
"key": "kp",
"unit": "",
"min": 0.0,
"min": -10.0,
"max": 10.0,
"step": 0.01,
"default": 1.0,
Expand All @@ -39,7 +43,7 @@
"name": "Ki",
"key": "ki",
"unit": "",
"min": 0.0,
"min": -10.0,
"max": 10.0,
"step": 0.01,
"default": 0.1,
Expand All @@ -49,7 +53,7 @@
"name": "Kd",
"key": "kd",
"unit": "",
"min": 0.0,
"min": -10.0,
"max": 10.0,
"step": 0.01,
"default": 0.05,
Expand Down Expand Up @@ -158,36 +162,53 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, desc: dict) -> None:
# 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)
input_range_min = opts.get(
CONF_INPUT_RANGE_MIN,
data.get(CONF_INPUT_RANGE_MIN, DEFAULT_INPUT_RANGE_MIN),
)
range_max = opts.get(
CONF_RANGE_MAX, data.get(CONF_RANGE_MAX, DEFAULT_RANGE_MAX)
input_range_max = opts.get(
CONF_INPUT_RANGE_MAX,
data.get(CONF_INPUT_RANGE_MAX, DEFAULT_INPUT_RANGE_MAX),
)
output_range_min = opts.get(
CONF_OUTPUT_RANGE_MIN,
data.get(CONF_OUTPUT_RANGE_MIN, DEFAULT_OUTPUT_RANGE_MIN),
)
output_range_max = opts.get(
CONF_OUTPUT_RANGE_MAX,
data.get(CONF_OUTPUT_RANGE_MAX, DEFAULT_OUTPUT_RANGE_MAX),
)

if self._key == "setpoint":
min_val, max_val = range_min, range_max
min_val, max_val = input_range_min, input_range_max
elif self._key == "output_min":
min_val, max_val = -abs(range_max), range_min
min_val, max_val = output_range_min, output_range_max
elif self._key == "output_max":
min_val, max_val = range_min, range_max
min_val, max_val = output_range_min, output_range_max
else:
_LOGGER.error("Unexpected PID parameter key: %s", self._key)
min_val, max_val = DEFAULT_RANGE_MIN, DEFAULT_RANGE_MAX
_LOGGER.error(
"Unknown PID key '%s'. Using default values: input_min=%s, input_max=%s, output_min=%s, output_max=%s",
self._key,
DEFAULT_INPUT_RANGE_MIN,
DEFAULT_INPUT_RANGE_MAX,
DEFAULT_OUTPUT_RANGE_MIN,
DEFAULT_OUTPUT_RANGE_MAX,
)
min_val, max_val = DEFAULT_INPUT_RANGE_MIN, DEFAULT_INPUT_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
if self._key == "setpoint":
self._attr_native_value = range_min + (range_max - range_min) * float(
desc["default"]
)
self._attr_native_value = input_range_min + (
input_range_max - input_range_min
) * float(desc["default"])
elif self._key == "output_min":
self._attr_native_value = range_min
self._attr_native_value = output_range_min
elif self._key == "output_max":
self._attr_native_value = range_max
self._attr_native_value = output_range_max
else:
_LOGGER.error("Unexpected error, unknown state in number.py")

Expand Down
Loading