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
1 change: 1 addition & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
name: Lint

on:
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ Built for [Brunata Online](https://online.brunata.com) accounts. If Brunata Onli
- Groups sensors under devices for easy management.
- Standard Home Assistant device classes and state classes, with full Long Term Statistics support.
- Polls Brunata Online once an hour at a fixed time between xx:58:30 and xx:59:30. This time is automatically chosen when the integration is installed and remains the same afterwards. How often a *new* reading actually appears depends on the meter's own reporting interval, not on this schedule.
- When nothing has changed for several hours in a row, polling drops to once every four hours until a reading moves. Brunata's meters report rarely, and this keeps the integration from asking for the same numbers 24 times a day.

---

Expand Down
89 changes: 14 additions & 75 deletions custom_components/brunata/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,25 +39,6 @@
_POLL_WINDOW_BASE_SECOND = 30
_POLL_WINDOW_SPREAD_SECONDS = 60

# Brunata's meters report rarely: a heat cost allocator can go weeks between
# readings, and even the water meters change at most a few times a day. An
# hourly poll is therefore already far more often than the data changes, and
# most polls return a payload byte-identical to the last one.
#
# So after this many consecutive polls where no meter's reading or date moved,
# the schedule drops to one poll every _IDLE_POLL_EVERY_HOURS hours. Any change
# at all puts it straight back on the hour.
#
# The cost, stated plainly: a reading that arrives during a skipped hour is
# recorded up to four hours late, and Home Assistant attributes consumption to
# the hour it was *polled*, not the hour Brunata dated it. The alignment was
# already coarse — a reading dated three days ago is recorded today either way
# — so this trades a little more coarseness for roughly a quarter of the
# requests during quiet periods. Six unchanged polls before backing off means
# a normally-reporting water meter never reaches the idle state at all.
_IDLE_AFTER_UNCHANGED_POLLS = 6
_IDLE_POLL_EVERY_HOURS = 4

# How long to stay away after an HTTP 429 with no usable Retry-After. One hour
# is the next scheduled poll anyway, so this only bites when Brunata asks for
# longer than that.
Expand Down Expand Up @@ -241,11 +222,6 @@ def __init__(
# async_remove_config_entry_device() has to take an id back out when
# the device and its entity are gone.
self.known_meter_ids: set[str] = set()
# What the last successful poll returned, reduced to the fields that
# decide whether a poll was worth making. See async_should_poll().
self._readings_fingerprint: tuple | None = None
self._unchanged_polls = 0
self._skipped_ticks = 0
# Set when Brunata answers 429. See async_should_poll().
self._rate_limited_until: datetime | None = None
super().__init__(
Expand All @@ -264,15 +240,19 @@ def __init__(
def async_should_poll(self, now: datetime) -> bool:
"""Decide whether this scheduled tick is worth a request.

Two reasons to stay quiet, in order:

1. Brunata answered 429 and asked us to wait. That is the one status
where retrying quickly is actively harmful — the server has just
said we are asking too much — so it is honoured to the second.
2. Nothing has changed for a while. See _IDLE_AFTER_UNCHANGED_POLLS.

Deliberately not "skip if the newest reading is old": that would guess
when Brunata publishes. This only counts what actually happened.
There is exactly one reason to stay quiet: Brunata answered 429 and
asked us to wait. That is the one status where retrying quickly is
actively harmful — the server has just said we are asking too much —
so it is honoured to the second. Every other tick polls.

An adaptive schedule was tried here and rolled back: after a run of
polls whose readings had not moved, it dropped to one poll every four
hours. It saved requests during quiet periods, but a reading arriving
in a skipped hour was recorded up to four hours late, and Home
Assistant attributes consumption to the hour it was *polled*, not the
hour Brunata dated it. Polling on the hour, every hour, keeps that
error bounded at one hour. Do not reintroduce the backoff without
deciding that trade differently on purpose.
"""
if self._rate_limited_until is not None:
if now < self._rate_limited_until:
Expand All @@ -283,50 +263,12 @@ def async_should_poll(self, now: datetime) -> bool:
return False
self._rate_limited_until = None

if self._unchanged_polls < _IDLE_AFTER_UNCHANGED_POLLS:
return True

self._skipped_ticks += 1
if self._skipped_ticks < _IDLE_POLL_EVERY_HOURS:
_LOGGER.debug(
"Skipping this poll: %s consecutive polls returned unchanged "
"readings, so polling every %s hours until something moves",
self._unchanged_polls,
_IDLE_POLL_EVERY_HOURS,
)
return False

self._skipped_ticks = 0
return True

def _note_readings(self, meters: dict[str, BrunataMeter]) -> None:
"""Record whether this payload said anything new.

Only the reading and its date are compared. Placement, transmitting and
the rest can change without any new measurement existing, and it is the
measurement that decides whether polling this often is earning its
requests.
"""
fingerprint = tuple(
sorted(
(meter.meter_id, meter.reading_date, meter.value)
for meter in meters.values()
)
)
if fingerprint == self._readings_fingerprint:
self._unchanged_polls += 1
return

if self._unchanged_polls >= _IDLE_AFTER_UNCHANGED_POLLS:
_LOGGER.debug("Readings moved — back to polling every hour")
self._readings_fingerprint = fingerprint
self._unchanged_polls = 0
self._skipped_ticks = 0

async def _async_update_data(self) -> dict[str, BrunataMeter]:
"""Fetch data from the API."""
try:
meters = await self.client.async_get_meters()
return await self.client.async_get_meters()
except BrunataAuthError as err:
# Propagates so Home Assistant starts the re-authentication flow.
raise ConfigEntryAuthFailed(str(err)) from err
Expand All @@ -352,6 +294,3 @@ async def _async_update_data(self) -> dict[str, BrunataMeter]:
self._rate_limited_until,
)
raise UpdateFailed(str(err)) from err

self._note_readings(meters)
return meters
1 change: 1 addition & 0 deletions custom_components/brunata/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Config flow for Brunata integration."""

from __future__ import annotations

import logging
Expand Down
2 changes: 1 addition & 1 deletion custom_components/brunata/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/MSL-DA/brunata_online/issues",
"requirements": [],
"version": "1.4.0"
"version": "1.4.1"
}
1 change: 1 addition & 0 deletions custom_components/brunata/sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Support for Brunata meters."""

from __future__ import annotations

import logging
Expand Down
2 changes: 1 addition & 1 deletion requirements_lint.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# ruff.toml selects rules explicitly, so a Ruff release that changes the
# default rule set does not change what runs here. A bump can still surface new
# findings within the selected rules; take them one release at a time.
ruff==0.14.2
ruff==0.16.5
11 changes: 8 additions & 3 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@
'<input name="username"><input name="password"></form></body></html>'
)

# What Brunata's token endpoint reports as the access token's lifetime. Measured
# rather than assumed: a debug log from 30 August 2026 shows the client renewing
# via the refresh token on every second hourly poll and skipping it on the
# others, which puts the real lifetime between 46 and 104 minutes.
TOKEN_LIFETIME_SECONDS = 3600

TOKEN_RESPONSE = {
"access_token": "new-access-token",
"refresh_token": "new-refresh-token",
"token_type": "Bearer",
"expires_in": 300,
"refresh_expires_in": 1800,
"expires_in": TOKEN_LIFETIME_SECONDS,
}


Expand Down Expand Up @@ -323,7 +328,7 @@ async def test_expiry_is_derived_from_expires_in_only():
http = FakeHttpClient()
client = make_client(http)

client._store_tokens({"access_token": "A", "expires_in": 300})
client._store_tokens({"access_token": "A", "expires_in": TOKEN_LIFETIME_SECONDS})
assert client._token_is_usable is True

client._store_tokens({"access_token": "B"})
Expand Down
1 change: 1 addition & 0 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Test Brunata config flow."""

from unittest.mock import patch

from homeassistant import config_entries, data_entry_flow
Expand Down
58 changes: 11 additions & 47 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import asyncio
import logging
from dataclasses import replace
from datetime import timedelta
from unittest.mock import AsyncMock, patch

Expand Down Expand Up @@ -322,63 +321,28 @@ async def test_unloading_stops_the_schedule(
assert mock_brunata_client.async_get_meters.call_count == calls_after_unload


# --- not polling more often than the data changes ---------------------------
# --- staying away only when Brunata asks -----------------------------------


async def test_unchanged_readings_back_the_schedule_off(
async def test_unchanged_readings_do_not_slow_the_schedule(
hass: HomeAssistant, mock_brunata_client, mock_meter
):
"""Brunata's meters report rarely, so most polls return the same payload.
"""The schedule is unconditional: every hour, whatever the payload said.

After six consecutive unchanged polls the schedule drops to one poll every
four hours. The counting is on the readings only — reading date and value —
because a changed placement is not a reason to keep asking hourly.
An adaptive backoff after a run of unchanged readings was tried and rolled
back — see async_should_poll(). It is cheap to reintroduce by accident, and
the cost is invisible in normal use: readings would still arrive, just
attributed to a later hour than the one they belong to. This test is what
makes that regression fail loudly instead of silently.
"""
entry, _ = await _setup_with_meter(hass, mock_brunata_client, mock_meter)
coordinator = entry.runtime_data
now = dt_util.utcnow()

# The first refresh happened during setup. Five more identical ones reach
# the threshold without crossing it.
for _ in range(5):
assert coordinator.async_should_poll(now) is True
# Well past any threshold a backoff would plausibly use.
for _ in range(12):
await coordinator.async_refresh()
assert coordinator._unchanged_polls == 5

assert coordinator.async_should_poll(now) is True
await coordinator.async_refresh()
assert coordinator._unchanged_polls == 6

# Now three ticks are skipped and the fourth polls.
assert coordinator.async_should_poll(now) is False
assert coordinator.async_should_poll(now) is False
assert coordinator.async_should_poll(now) is False
assert coordinator.async_should_poll(now) is True


async def test_a_new_reading_puts_the_schedule_back_on_the_hour(
hass: HomeAssistant, mock_brunata_client, mock_meter
):
"""Backing off must not delay the next real reading by more than one cycle.

A payload that differs in reading date or value resets the counter, so the
following hour is polled again.
"""
entry, _ = await _setup_with_meter(hass, mock_brunata_client, mock_meter)
coordinator = entry.runtime_data
now = dt_util.utcnow()

for _ in range(6):
await coordinator.async_refresh()
assert coordinator.async_should_poll(now) is False

mock_brunata_client.async_get_meters = AsyncMock(
return_value={"12345": replace(mock_meter, value=999.0)}
)
await coordinator.async_refresh()

assert coordinator._unchanged_polls == 0
assert coordinator.async_should_poll(now) is True
assert coordinator.async_should_poll(now) is True


async def test_a_rate_limit_is_honoured_to_the_second(
Expand Down
1 change: 1 addition & 0 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Test Brunata sensor."""

import logging
from dataclasses import replace
from datetime import UTC, date, datetime
Expand Down