Skip to content

Commit 6cf23b9

Browse files
bakerkjclaude
andcommitted
feat: tunable per-window (1m/5m/15m) update intervals
The single publish interval drove one coordinator that refreshed every sensor together. Split into three coordinators (one per averaging window) sharing the existing sampler; each sensor binds to its window's coordinator, while non-window sensors (max_mhz, epp, epb) and the summary follow the 1m/primary window. Slower windows can now publish (and record) far less often than the responsive 1m window. The config/options flow exposes a per-window interval; each is validated to be >= the sample interval. Backward compatible: resolve_publish_ interval() seeds every window from the legacy single publish_interval_seconds, so existing entries are unchanged until tuned (no config-entry migration needed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 933d2f3 commit 6cf23b9

7 files changed

Lines changed: 295 additions & 82 deletions

File tree

custom_components/cpu_capacity/__init__.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
from homeassistant.core import HomeAssistant
1111

1212
from .const import (
13-
CONF_PUBLISH_INTERVAL_SECONDS,
1413
CONF_SAMPLE_INTERVAL_SECONDS,
15-
DEFAULT_PUBLISH_INTERVAL_SECONDS,
1614
DEFAULT_SAMPLE_INTERVAL_SECONDS,
1715
DOMAIN,
16+
MAX_PUBLISH_INTERVAL,
17+
MIN_PUBLISH_INTERVAL,
1818
PLATFORMS,
19+
PUBLISH_INTERVAL_CONF_BY_WINDOW,
20+
resolve_publish_interval,
1921
)
2022
from .coordinator import CpuCapacityCoordinator, CpuCapacitySampler
2123

@@ -25,7 +27,7 @@
2527
@dataclass
2628
class CpuCapacityEntryData:
2729
sampler: CpuCapacitySampler
28-
coordinator: CpuCapacityCoordinator
30+
coordinators: dict[str, CpuCapacityCoordinator]
2931

3032

3133
def _entry_float(entry: ConfigEntry, key: str, default: float) -> float:
@@ -47,33 +49,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
4749
entry, CONF_SAMPLE_INTERVAL_SECONDS, DEFAULT_SAMPLE_INTERVAL_SECONDS
4850
),
4951
)
50-
publish_interval = max(
51-
1.0,
52-
_entry_float(
53-
entry, CONF_PUBLISH_INTERVAL_SECONDS, DEFAULT_PUBLISH_INTERVAL_SECONDS
54-
),
55-
)
56-
if publish_interval < sample_interval:
57-
publish_interval = sample_interval
52+
source = {**entry.data, **entry.options}
53+
publish_intervals = {
54+
window: min(
55+
MAX_PUBLISH_INTERVAL,
56+
max(
57+
MIN_PUBLISH_INTERVAL,
58+
sample_interval,
59+
resolve_publish_interval(window, source),
60+
),
61+
)
62+
for window in PUBLISH_INTERVAL_CONF_BY_WINDOW
63+
}
5864

5965
logger = _LOGGER.getChild(entry.entry_id)
6066

6167
sampler = CpuCapacitySampler(
6268
hass,
6369
logger,
6470
sample_interval_seconds=sample_interval,
65-
publish_interval_seconds=publish_interval,
71+
publish_intervals_by_window=publish_intervals,
6672
)
67-
coordinator = CpuCapacityCoordinator(hass, logger, sampler)
73+
coordinators = {
74+
window: CpuCapacityCoordinator(
75+
hass, logger, sampler, window, publish_intervals[window]
76+
)
77+
for window in PUBLISH_INTERVAL_CONF_BY_WINDOW
78+
}
6879
entry_added = False
6980

7081
try:
7182
await sampler.async_start()
72-
await coordinator.async_config_entry_first_refresh()
83+
for coordinator in coordinators.values():
84+
await coordinator.async_config_entry_first_refresh()
7385

7486
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = CpuCapacityEntryData(
7587
sampler=sampler,
76-
coordinator=coordinator,
88+
coordinators=coordinators,
7789
)
7890
entry_added = True
7991

custom_components/cpu_capacity/config_flow.py

Lines changed: 50 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from __future__ import annotations
55

6+
from collections.abc import Mapping
67
from typing import Any
78

89
import voluptuous as vol
@@ -11,7 +12,6 @@
1112
from homeassistant.core import callback
1213

1314
from .const import (
14-
CONF_PUBLISH_INTERVAL_SECONDS,
1515
CONF_SAMPLE_INTERVAL_SECONDS,
1616
DEFAULT_NAME,
1717
DEFAULT_PUBLISH_INTERVAL_SECONDS,
@@ -21,29 +21,39 @@
2121
MAX_SAMPLE_INTERVAL,
2222
MIN_PUBLISH_INTERVAL,
2323
MIN_SAMPLE_INTERVAL,
24+
PUBLISH_INTERVAL_CONF_BY_WINDOW,
2425
UNIQUE_ID,
26+
resolve_publish_interval,
2527
)
2628

2729

28-
def _build_schema(sample_default: float, publish_default: float) -> vol.Schema:
29-
return vol.Schema(
30-
{
31-
vol.Required(
32-
CONF_SAMPLE_INTERVAL_SECONDS,
33-
default=float(sample_default),
34-
): vol.All(
35-
vol.Coerce(float),
36-
vol.Range(min=MIN_SAMPLE_INTERVAL, max=MAX_SAMPLE_INTERVAL),
37-
),
38-
vol.Required(
39-
CONF_PUBLISH_INTERVAL_SECONDS,
40-
default=float(publish_default),
41-
): vol.All(
42-
vol.Coerce(float),
43-
vol.Range(min=MIN_PUBLISH_INTERVAL, max=MAX_PUBLISH_INTERVAL),
44-
),
45-
}
46-
)
30+
def _build_schema(
31+
sample_default: float, publish_defaults: Mapping[str, float]
32+
) -> vol.Schema:
33+
schema: dict[Any, Any] = {
34+
vol.Required(
35+
CONF_SAMPLE_INTERVAL_SECONDS,
36+
default=float(sample_default),
37+
): vol.All(
38+
vol.Coerce(float),
39+
vol.Range(min=MIN_SAMPLE_INTERVAL, max=MAX_SAMPLE_INTERVAL),
40+
),
41+
}
42+
for window, conf in PUBLISH_INTERVAL_CONF_BY_WINDOW.items():
43+
schema[vol.Required(conf, default=float(publish_defaults[window]))] = vol.All(
44+
vol.Coerce(float),
45+
vol.Range(min=MIN_PUBLISH_INTERVAL, max=MAX_PUBLISH_INTERVAL),
46+
)
47+
return vol.Schema(schema)
48+
49+
50+
def _publish_error(user_input: dict[str, Any]) -> str | None:
51+
"""Each window must publish no faster than the sampler produces data."""
52+
sample_interval = float(user_input[CONF_SAMPLE_INTERVAL_SECONDS])
53+
for conf in PUBLISH_INTERVAL_CONF_BY_WINDOW.values():
54+
if float(user_input[conf]) < sample_interval:
55+
return "publish_too_small"
56+
return None
4757

4858

4959
@config_entries.HANDLERS.register(DOMAIN)
@@ -66,10 +76,9 @@ async def async_step_user(
6676
errors: dict[str, str] = {}
6777

6878
if user_input is not None:
69-
sample_interval = float(user_input[CONF_SAMPLE_INTERVAL_SECONDS])
70-
publish_interval = float(user_input[CONF_PUBLISH_INTERVAL_SECONDS])
71-
if publish_interval < sample_interval:
72-
errors["base"] = "publish_too_small"
79+
error = _publish_error(user_input)
80+
if error:
81+
errors["base"] = error
7382
else:
7483
await self.async_set_unique_id(UNIQUE_ID)
7584
self._abort_if_unique_id_configured()
@@ -79,7 +88,10 @@ async def async_step_user(
7988
step_id="user",
8089
data_schema=_build_schema(
8190
DEFAULT_SAMPLE_INTERVAL_SECONDS,
82-
DEFAULT_PUBLISH_INTERVAL_SECONDS,
91+
{
92+
window: DEFAULT_PUBLISH_INTERVAL_SECONDS
93+
for window in PUBLISH_INTERVAL_CONF_BY_WINDOW
94+
},
8395
),
8496
errors=errors,
8597
)
@@ -95,34 +107,26 @@ async def async_step_init(
95107
errors: dict[str, str] = {}
96108

97109
if user_input is not None:
98-
sample_interval = float(user_input[CONF_SAMPLE_INTERVAL_SECONDS])
99-
publish_interval = float(user_input[CONF_PUBLISH_INTERVAL_SECONDS])
100-
if publish_interval < sample_interval:
101-
errors["base"] = "publish_too_small"
110+
error = _publish_error(user_input)
111+
if error:
112+
errors["base"] = error
102113
else:
103114
return self.async_create_entry(title="", data=user_input)
104115

116+
source = {**self._config_entry.data, **self._config_entry.options}
105117
sample_default = float(
106-
self._config_entry.options.get(
107-
CONF_SAMPLE_INTERVAL_SECONDS,
108-
self._config_entry.data.get(
109-
CONF_SAMPLE_INTERVAL_SECONDS,
110-
DEFAULT_SAMPLE_INTERVAL_SECONDS,
111-
),
112-
)
113-
)
114-
publish_default = float(
115-
self._config_entry.options.get(
116-
CONF_PUBLISH_INTERVAL_SECONDS,
117-
self._config_entry.data.get(
118-
CONF_PUBLISH_INTERVAL_SECONDS,
119-
DEFAULT_PUBLISH_INTERVAL_SECONDS,
120-
),
121-
)
118+
source.get(CONF_SAMPLE_INTERVAL_SECONDS, DEFAULT_SAMPLE_INTERVAL_SECONDS)
122119
)
120+
# Pre-existing entries only have the legacy single value;
121+
# resolve_publish_interval seeds every window from it so the form
122+
# opens reflecting the current behaviour.
123+
publish_defaults = {
124+
window: resolve_publish_interval(window, source)
125+
for window in PUBLISH_INTERVAL_CONF_BY_WINDOW
126+
}
123127

124128
return self.async_show_form(
125129
step_id="init",
126-
data_schema=_build_schema(sample_default, publish_default),
130+
data_schema=_build_schema(sample_default, publish_defaults),
127131
errors=errors,
128132
)

custom_components/cpu_capacity/const.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,31 @@
11
# Copyright (c) 2026 Kenneth Baker <bakerkj@umich.edu>
22
# All rights reserved.
33

4+
from collections.abc import Mapping
5+
from typing import Any
6+
47
from homeassistant.const import Platform
58

69
DOMAIN = "cpu_capacity"
710
PLATFORMS: list[Platform] = [Platform.SENSOR]
811

912
CONF_SAMPLE_INTERVAL_SECONDS = "sample_interval_seconds"
13+
# Legacy single publish interval. Still read as the fallback for any window
14+
# whose per-window key is absent, so pre-existing config entries keep their
15+
# previous behaviour until the windows are tuned individually.
1016
CONF_PUBLISH_INTERVAL_SECONDS = "publish_interval_seconds"
17+
CONF_PUBLISH_INTERVAL_1M_SECONDS = "publish_interval_1m_seconds"
18+
CONF_PUBLISH_INTERVAL_5M_SECONDS = "publish_interval_5m_seconds"
19+
CONF_PUBLISH_INTERVAL_15M_SECONDS = "publish_interval_15m_seconds"
20+
21+
# Ordered so callers iterate windows deterministically (forms, refreshes).
22+
PUBLISH_INTERVAL_CONF_BY_WINDOW: dict[str, str] = {
23+
"1m": CONF_PUBLISH_INTERVAL_1M_SECONDS,
24+
"5m": CONF_PUBLISH_INTERVAL_5M_SECONDS,
25+
"15m": CONF_PUBLISH_INTERVAL_15M_SECONDS,
26+
}
27+
# Non-window sensors (max_mhz, epp, epb, summary) follow this window's cadence.
28+
PRIMARY_WINDOW = "1m"
1129

1230
DEFAULT_SAMPLE_INTERVAL_SECONDS = 0.5
1331
DEFAULT_PUBLISH_INTERVAL_SECONDS = 15.0
@@ -22,6 +40,26 @@
2240
STALE_DATA_TIMEOUT_MULTIPLIER = 3.0
2341
SUMMARY_SENSOR_NAME = "Summary"
2442

43+
44+
def resolve_publish_interval(window: str, source: Mapping[str, Any]) -> float:
45+
"""Resolve the publish interval for a window from a config entry mapping.
46+
47+
``source`` is the merged ``{**entry.data, **entry.options}`` mapping. A
48+
per-window value wins; otherwise the legacy single
49+
``publish_interval_seconds`` is used; otherwise the default. Invalid
50+
values fall back to the default rather than raising.
51+
"""
52+
raw = source.get(PUBLISH_INTERVAL_CONF_BY_WINDOW[window])
53+
if raw is None:
54+
raw = source.get(
55+
CONF_PUBLISH_INTERVAL_SECONDS, DEFAULT_PUBLISH_INTERVAL_SECONDS
56+
)
57+
try:
58+
return float(raw)
59+
except (TypeError, ValueError):
60+
return float(DEFAULT_PUBLISH_INTERVAL_SECONDS)
61+
62+
2563
EPP_PATH_TEMPLATE = (
2664
"/sys/devices/system/cpu/cpu{cpu}/cpufreq/energy_performance_preference"
2765
)

custom_components/cpu_capacity/coordinator.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class CoordinatorSnapshot(TypedDict):
5252
sample_count: int
5353
last_sample_epoch: float
5454
sample_interval_seconds: float
55-
publish_interval_seconds: float
55+
publish_intervals_by_window: dict[str, float]
5656
cpus: dict[int, CpuSnapshot]
5757

5858

@@ -277,15 +277,20 @@ def __init__(
277277
hass: HomeAssistant,
278278
logger: logging.Logger,
279279
sample_interval_seconds: float,
280-
publish_interval_seconds: float,
280+
publish_intervals_by_window: dict[str, float],
281281
) -> None:
282282
self.hass = hass
283283
self.logger = logger
284284
self._sample_interval_seconds = max(0.1, float(sample_interval_seconds))
285-
self._publish_interval_seconds = max(
286-
self._sample_interval_seconds,
287-
float(publish_interval_seconds),
288-
)
285+
# Each window publishes no faster than the sampler produces data.
286+
self._publish_intervals_by_window: dict[str, float] = {
287+
window: max(
288+
self._sample_interval_seconds,
289+
float(publish_intervals_by_window[window]),
290+
)
291+
for window in WINDOW_SECONDS
292+
if window in publish_intervals_by_window
293+
}
289294

290295
self._window_sizes: dict[str, int] = {
291296
label: max(1, int(math.ceil(seconds / self._sample_interval_seconds)))
@@ -329,8 +334,8 @@ def sample_interval_seconds(self) -> float:
329334
return self._sample_interval_seconds
330335

331336
@property
332-
def publish_interval_seconds(self) -> float:
333-
return self._publish_interval_seconds
337+
def publish_intervals_by_window(self) -> dict[str, float]:
338+
return dict(self._publish_intervals_by_window)
334339

335340
async def async_start(self) -> None:
336341
if self._running:
@@ -486,24 +491,35 @@ def _build_snapshot_sync(self) -> CoordinatorSnapshot:
486491
sample_count=self._sample_count,
487492
last_sample_epoch=self._last_sample_epoch,
488493
sample_interval_seconds=self._sample_interval_seconds,
489-
publish_interval_seconds=self._publish_interval_seconds,
494+
publish_intervals_by_window=dict(self._publish_intervals_by_window),
490495
cpus=cpu_data,
491496
)
492497

493498

494499
class CpuCapacityCoordinator(DataUpdateCoordinator[CoordinatorSnapshot]):
500+
"""Publishes the shared sampler's data at one window's cadence.
501+
502+
One instance per averaging window (1m/5m/15m); all instances share a
503+
single :class:`CpuCapacitySampler`, so each refresh only snapshots the
504+
already-collected rolling averages.
505+
"""
506+
495507
def __init__(
496508
self,
497509
hass: HomeAssistant,
498510
logger: logging.Logger,
499511
sampler: CpuCapacitySampler,
512+
window: str,
513+
publish_interval_seconds: float,
500514
) -> None:
501515
self.sampler = sampler
516+
self.window = window
517+
self._publish_interval_seconds = publish_interval_seconds
502518
super().__init__(
503519
hass,
504520
logger,
505-
name=DOMAIN,
506-
update_interval=timedelta(seconds=sampler.publish_interval_seconds),
521+
name=f"{DOMAIN}_{window}",
522+
update_interval=timedelta(seconds=publish_interval_seconds),
507523
)
508524

509525
async def _async_update_data(self) -> CoordinatorSnapshot:
@@ -516,7 +532,7 @@ async def _async_update_data(self) -> CoordinatorSnapshot:
516532
raise UpdateFailed("No sample timestamp available")
517533

518534
stale_timeout = max(
519-
5.0, self.sampler.publish_interval_seconds * STALE_DATA_TIMEOUT_MULTIPLIER
535+
5.0, self._publish_interval_seconds * STALE_DATA_TIMEOUT_MULTIPLIER
520536
)
521537
age = time.time() - snapshot["last_sample_epoch"]
522538
if age > stale_timeout:

0 commit comments

Comments
 (0)