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
4 changes: 3 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ updates:
# There is deliberately no pip entry. requirements-test.txt pins
# pytest-homeassistant-custom-component to match one Home Assistant release,
# so moving it is a decision about which Home Assistant version to support,
# not a chore to automate.
# not a chore to automate. The `latest HA` job in validate.yml is what says
# whether the newer release is safe to move to, which is the question a
# Dependabot pull request here could not answer.
- package-ecosystem: github-actions
directory: "/"
schedule:
Expand Down
74 changes: 73 additions & 1 deletion .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ on:
branches: [main]
pull_request:
# Weekly, so a Home Assistant release that breaks the integration is caught
# without waiting for someone to push.
# without waiting for someone to push. Only the `latest HA` job below can
# actually see that: every other job installs a pinned core and would pass on
# Monday for the same reason it passed on the last push.
schedule:
- cron: "0 4 * * 1"
# So the weekly jobs can be run on demand rather than only on Monday, which
# matters when a Home Assistant beta is already suspected of breaking things.
workflow_dispatch:

# Nothing here writes to the repository, so take less than the repository
# default may otherwise grant.
Expand Down Expand Up @@ -78,3 +83,70 @@ jobs:
# every honest refactor a CI failure. Raise it when the real figure has
# settled well above it, never lower it to make a red build pass.
- run: pytest -q --cov-fail-under=95

# HACS refuses to install the integration on a core older than hacs.json's
# `homeassistant` key, so that key is a promise that the code imports there.
# It was wrong once already: the declared floor sat at 2025.2.0 while every
# platform imported a name that only exists from 2025.3.0, which HACS turns
# into a traceback on the user's box instead of the "needs a newer Home
# Assistant" message it exists to produce. Nothing else in this workflow can
# notice, because everything else installs one pinned modern core.
minimum-ha:
name: minimum HA
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
# Home Assistant supports a narrow band of Python versions, so this
# tracks the declared floor rather than the version the other jobs
# use. Move it when the floor moves past what 3.13 can install.
python-version: "3.13"
# Reading the floor back out of hacs.json rather than repeating it here is
# the point: a second pin could drift from the claim it is meant to guard.
- run: pip install "homeassistant==$(python -c 'import json; print(json.load(open("hacs.json"))["homeassistant"])')"
# Importing every module is enough to catch this class of break, because
# the imports that fail are unconditional and at module scope. Running the
# suite here is not, and would fail on unrelated API drift across the year
# or more between the floor and the version the tests are written against.
- run: |
python - <<'PY'
import importlib
import pathlib
import sys

sys.path.insert(0, ".")
package = "custom_components.blebox_advanced"
for path in sorted(pathlib.Path(package.replace(".", "/")).glob("*.py")):
name = package if path.stem == "__init__" else f"{package}.{path.stem}"
importlib.import_module(name)
print("imported", name)
PY

# The job the weekly schedule exists for. `tests` above pins
# pytest-homeassistant-custom-component, which pins one exact Home Assistant,
# so it can never see a core release that removed or renamed an API this
# integration uses. This one installs whatever shipped since.
latest-ha:
name: latest HA
# Scheduled and manual only, on purpose. An upstream release breaking the
# integration is not the contributor's fault, and this must never be able to
# redden their pull request. Keeping it off `pull_request` entirely is
# stronger than continue-on-error, which would also hide a real break behind
# a green run.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
# Matches `tests`. If Home Assistant outgrows it, pip fails to resolve
# here first, which is itself the signal to bump both.
python-version: "3.14"
# Deliberately not -r requirements-test.txt: that file is what makes this
# run pointless. pytest-cov is named because pytest.ini always measures
# coverage, so the run needs it even though it also arrives transitively.
- run: pip install pytest-homeassistant-custom-component pytest-cov
# No --cov-fail-under: this run is about whether the integration still
# works against current Home Assistant, not about coverage.
- run: pytest -q
36 changes: 30 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,36 @@ ruff check custom_components tests
ruff format custom_components tests
```

CI runs `hassfest`, HACS validation, `ruff check`, `ruff format --check` and the
test suite on every pull request and on every push to `main`, and a lint finding
fails the build like a test does. CI only checks formatting, it does not rewrite
anything, so run `ruff format` yourself. A weekly scheduled run repeats all of
it, which is how a Home Assistant release breaking the integration gets noticed
without anyone pushing.
CI runs `hassfest`, HACS validation, `ruff check`, `ruff format --check`, an
import check against the declared minimum Home Assistant version and the test
suite on every pull request and on every push to `main`. CI only checks
formatting, it does not rewrite anything, so run `ruff format` yourself.

Which of those actually block a merge is branch protection on `main`, not the
workflow. It currently requires only `hassfest`, `HACS` and `tests`, so a red
`ruff` job shows a failed check and still leaves the merge button live. The
check names that should be marked required are:

```
hassfest
HACS
ruff
tests
```

`minimum HA` is intentionally not on that list yet. It installs an old Home
Assistant release from PyPI, so an upstream packaging problem could block merges
for a reason that has nothing to do with the change under review. Add it once it
has proved steady.

The weekly scheduled run adds the one job a pinned build cannot do. `tests`
installs `requirements-test.txt`, which pins one exact Home Assistant, so it
proves the same thing every Monday that it proved on the last push. `latest HA`
installs `pytest-homeassistant-custom-component` unpinned and runs the suite
against whatever Home Assistant has shipped since, which is how a core release
breaking the integration gets noticed without anyone pushing. It runs only on
the schedule and on `workflow_dispatch`, never on a pull request, because an
upstream break is not the contributor's to fix under review.

## Commit messages

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ and `actionBox` hardware should work too.

## Installation

Requires Home Assistant **2025.2.0** or newer. No cloud account, no BleBox app,
nothing exposed to the internet.
Requires Home Assistant **2025.3.0** or newer. Older cores are missing an
entity-platform API that every platform here imports, so the integration cannot
load at all on them, not even the button events. No cloud account, no BleBox
app, nothing exposed to the internet.

### HACS

Expand Down
30 changes: 27 additions & 3 deletions custom_components/blebox_advanced/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
DEFAULT_PORT,
DOMAIN,
MODE_MANUAL,
SETUP_REFRESH_TIMEOUT_S,
)
from .coordinator import (
BleBoxEventsConfigEntry,
Expand Down Expand Up @@ -92,11 +93,34 @@ async def async_setup_entry(
# Setup deliberately does not depend on the device answering. A device that
# is asleep, moved or on a temporarily unreachable VLAN must not remove the
# event entities, and manually configured callbacks keep arriving.
await coordinator.async_refresh()
#
# It should not spend long finding that out, either. The platforms decide
# what to create by inspecting `coordinator.data`, so this poll is only
# load-bearing while nothing is known about the device yet; once its shape
# has been remembered, the entities exist either way and the poll is asked
# for its values on a shorter deadline (`SETUP_REFRESH_TIMEOUT_S`).
if coordinator.data is None:
await coordinator.async_refresh()
else:
with manager.request_timeout(SETUP_REFRESH_TIMEOUT_S):
await coordinator.async_refresh()

snapshot = coordinator.data
await async_apply_provisioning(
hass, entry, state=snapshot.actions if snapshot else None
# Nothing in platform setup depends on the device having been provisioned:
# healing does not start until it has been attempted (see `_async_heal`),
# and a manual callback never needed it at all. On an unreachable device it
# is a second full timeout, and on a healthy first-time one it is nine round
# trips, so it runs alongside the platforms rather than in front of them.
#
# Tracked on the entry rather than backgrounded: unloading waits for a
# tracked task but cancels a background one, and a provisioning run cut off
# partway through leaves the device's slot table half written.
entry.async_create_task(
hass,
async_apply_provisioning(
hass, entry, state=snapshot.actions if snapshot else None
),
"provisioning",
)

if snapshot is None:
Expand Down
24 changes: 24 additions & 0 deletions custom_components/blebox_advanced/blebox_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@

import asyncio
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any

Expand Down Expand Up @@ -560,6 +562,28 @@ def base_url(self) -> str:
return f"http://{self._host}:{self._port}"
return f"http://{self._host}"

@contextmanager
def request_timeout(self, seconds: float) -> Iterator[None]:
"""Run the requests made inside this block on a shorter deadline.

The device timeout is deliberately generous, because an ESP-based device
on a busy Wi-Fi link is genuinely slow sometimes and giving up on it is
worse than waiting. There is one caller for whom that is the wrong trade
- setting a config entry up, where the answer is only needed to put live
values on entities that already exist - so it asks for a shorter one
rather than every other caller settling for it.

Not reentrant, and not safe to hold across a caller that must keep the
full deadline: it swaps the deadline for the whole manager, and requests
already in flight keep the one they started with.
"""
previous = self._timeout
self._timeout = aiohttp.ClientTimeout(total=seconds)
try:
yield
finally:
self._timeout = previous

# -- transport ----------------------------------------------------------

async def _get(self, path: str) -> Any:
Expand Down
46 changes: 41 additions & 5 deletions custom_components/blebox_advanced/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,48 @@
responsive here as it is there. Input events are pushed and never polled for.
"""

SLOW_REFRESH_EVERY: Final = 12
"""Fetch settings, actions and uptime once every N state polls (5s x 12 = 1min).
SETUP_REFRESH_TIMEOUT_S: Final = 3
"""Deadline for the first poll when the device's shape is already remembered.

Setting an entry up deliberately does not depend on the device answering, but
it still waited the whole device timeout to find that out, and until it had, not
one entity existed. Where ``CONF_DEVICE_CACHE`` already says what this device
has, that wait buys values and nothing else, and the ordinary poll five seconds
later fetches those anyway - so a device that is not there is given up on
sooner, and the entities it had come up unavailable rather than late.

A device that has never answered gets the full deadline instead: there the first
poll is the only thing that can create its entities at all.
"""

SLOW_REFRESH_SECONDS: Final = 60
"""How long between fetches of everything that is not relay and power state.

A full cycle costs five extra requests - device identity, action slots,
settings, network and uptime - and none of those changes on its own more than
occasionally, so polling them at the state cadence would be wasteful.

Measured as elapsed time rather than counted state polls. Entities ask for an
extra refresh whenever they write a setting or predict a relay move, and a
counter advanced on those too: a device whose button was in regular use polled
its metadata about twice as often as this says, which made the interval depend
on how much the household used the switch. A settings write still forces a full
refresh outright, so the cadence never delays a change made from Home Assistant.
"""

HEAL_BACKOFF_MAX_CYCLES: Final = 60
"""Longest gap between retries of a repair that keeps failing, in slow cycles.

Restoring callbacks that have gone missing is retried on the slow cycle, and
some failures cannot be cleared from Home Assistant at all - the documented one
is a device whose action slots are full of actions the user configured
themselves. Retrying that every cycle for as long as the entry is loaded costs a
request and an error log line a minute, forever, and fixes nothing, so retries
back off exponentially to at most about an hour.

These change rarely and cost an extra three requests, so polling them at the
state cadence would be wasteful. A settings write forces a full refresh anyway,
so the slow cycle never delays a change made from Home Assistant.
The backoff is not a way of waiting for the problem to go away: anything that
changes what the retry would actually do - a slot freed in the wBox app, a
different callback going missing - drops it and retries on the next cycle.
"""

WRITE_SETTLE_S: Final = 5.0
Expand Down
Loading
Loading