Skip to content

Commit c4084d0

Browse files
authored
fix: repair the dead stable channel and clear the review findings (#2)
* fix: repoint the stable channel at the published OTA manifest The stable manifest URL 404s: neither Faikin-S3-MINI-N4-R2-manifest.json (what we shipped) nor the Faikout- spelling exists on ota.faikout.uk. Every stable-channel user has been getting UpdateFailed on every refresh since v0.1.0. The published stable manifest is Faikout.manifest -- same "id": "Faikout-S3-MINI-N4-R2", self-referential url, and an app: true entry pointing at Faikout-S3-MINI-N4-R2.bin. Verified end to end: manifest -> app image -> HTTP 206 -> version c763929d. The bug hid because the only live check skipped on any FirmwareFetchError, so a 404 looked identical to an offline OTA server. Split FirmwareUnavailableError out of FirmwareFetchError for the case where the host never answered, and skip only on that -- a bad status now fails the run. Nothing in CI ran that check at all, so add a nightly OTA job to the Validate workflow, off the PR path so an upstream outage blocks nobody. Also in the OTA client, since the exception split touched it: - Bound the Range-ignored fallback to 512 bytes. A server that answers 200 instead of 206 was having its whole ~1.5 MB image buffered, defeating the point of the ranged request. - Fold the duplicated except-wrapper into one _fetch helper. - Replace a tautological `assert session.closed is False` (the fake never had a close()) with a real close-contract test. * fix: collapse the channel onto entry.options and fix partial-failure logging Remediation of the outstanding two-axis review findings. Config entry (schema v2, with migration): - The channel was written to entry.data at creation and to entry.options on change, so every reader consulted both -- three differently-defaulted spellings of the same lookup. It now lives in options alone; async_migrate_entry moves v1 entries across, preferring the options value as the one the user last chose, and refuses an entry from a newer release rather than guessing. - The config flow never checked MQTT, though the design requires an abort. It does now, once per flow on the way to the form rather than again on submit -- async_wait_for_mqtt_client can block for up to 50s while MQTT is still setting up. Coordinator logging: - A wholly-failed refresh is now left entirely to DataUpdateCoordinator, which already logs it once down and once recovered; we were logging it a second time alongside. - A *partial* failure counts as a successful refresh, so the base class says nothing and a missing target was invisible above debug. Those are reported here instead: unreachable-host warns once per outage with a matching recovery, while a 404 or bad metadata warns every refresh -- warn-once-then-silence is exactly how the dead stable manifest stayed hidden for two months. - UpdateFailed could render "...: None" when a channel resolved to no URLs at all; that case gets its own translated message. Smaller cleanups: - binary_sensor reached into the config entry for a channel the coordinator already holds; it now asks the coordinator, and the four repeated tracker lookups collapse into one _device property. - Guard the options read so an entry without a channel falls back to stable instead of raising KeyError out of setup. The design doc is updated to match on the two points settled by decision -- the Gold quality target (it said Silver) and the coordinator's deliberate whole-table iteration -- plus the FirmwareUnavailableError split and the corrected logging contract. * chore: release 0.2.0 Config entries move to schema v2 (channel in options, with migration) and the stable channel URL changes, so this is not a patch release.
1 parent a73b6ad commit c4084d0

22 files changed

Lines changed: 620 additions & 89 deletions

.github/workflows/validate.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ name: Validate
22
on:
33
push:
44
pull_request:
5+
workflow_dispatch:
56
schedule:
67
- cron: "0 0 * * *"
78

@@ -11,6 +12,20 @@ jobs:
1112
steps:
1213
- uses: actions/checkout@v4
1314
- uses: home-assistant/actions/hassfest@master
15+
ota:
16+
# The offline suite cannot notice a dead OTA URL: a 404 on the stable manifest
17+
# went unseen for two months because the only live check skipped on any fetch
18+
# error. Run it nightly, off the PR path, so an upstream outage blocks nobody.
19+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- uses: astral-sh/setup-uv@v6
24+
with:
25+
python-version: "3.13"
26+
- run: uv sync --dev
27+
- run: uv run pytest -m network
28+
1429
hacs:
1530
runs-on: ubuntu-latest
1631
steps:

custom_components/faikout/__init__.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,28 @@ class FaikoutRuntimeData:
2929
type FaikoutConfigEntry = ConfigEntry[FaikoutRuntimeData]
3030

3131

32+
async def async_migrate_entry(hass: HomeAssistant, entry: FaikoutConfigEntry) -> bool:
33+
if entry.version > 2:
34+
# Written by a newer release of this integration; refuse rather than guess.
35+
return False
36+
if entry.version == 1:
37+
# v1 wrote the channel to entry.data at creation and to entry.options on
38+
# change, so every reader had to consult both. Collapse onto options,
39+
# preferring the options value because it is the one the user last chose.
40+
data = dict(entry.data)
41+
stale_channel = data.pop(CONF_CHANNEL, Channel.STABLE.value)
42+
channel = entry.options.get(CONF_CHANNEL, stale_channel)
43+
hass.config_entries.async_update_entry(
44+
entry, data=data, options={**entry.options, CONF_CHANNEL: channel}, version=2
45+
)
46+
return True
47+
48+
3249
async def async_setup_entry(hass: HomeAssistant, entry: FaikoutConfigEntry) -> bool:
3350
if not await mqtt.async_wait_for_mqtt_client(hass):
3451
raise ConfigEntryNotReady(translation_domain=DOMAIN, translation_key="mqtt_unavailable")
3552

36-
channel = Channel(entry.options.get(CONF_CHANNEL, entry.data[CONF_CHANNEL]))
53+
channel = Channel(entry.options.get(CONF_CHANNEL, Channel.STABLE.value))
3754
client = FaikoutOtaClient(async_get_clientsession(hass))
3855
coordinator = FaikoutCoordinator(hass, client, channel)
3956
tracker = FaikoutDeviceTracker(hass)

custom_components/faikout/binary_sensor.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
from homeassistant.helpers.update_coordinator import CoordinatorEntity
1818

1919
from . import FaikoutConfigEntry
20-
from .const import CONF_CHANNEL, DOMAIN, MANUFACTURER, SIGNAL_DEVICE_UPDATE
20+
from .const import DOMAIN, MANUFACTURER, SIGNAL_DEVICE_UPDATE
2121
from .coordinator import FaikoutCoordinator
22-
from .device_tracker import FaikoutDeviceTracker
22+
from .device_tracker import FaikoutDevice, FaikoutDeviceTracker
2323

2424
PARALLEL_UPDATES = 0
2525

@@ -37,9 +37,7 @@ def _add(device_id: str) -> None:
3737
if device_id in known or device_id not in data.tracker.devices:
3838
return
3939
known.add(device_id)
40-
async_add_entities(
41-
[FirmwareUpdateBinarySensor(data.coordinator, data.tracker, entry, device_id)]
42-
)
40+
async_add_entities([FirmwareUpdateBinarySensor(data.coordinator, data.tracker, device_id)])
4341

4442
for device_id in list(data.tracker.devices):
4543
_add(device_id)
@@ -58,12 +56,10 @@ def __init__(
5856
self,
5957
coordinator: FaikoutCoordinator,
6058
tracker: FaikoutDeviceTracker,
61-
entry: FaikoutConfigEntry,
6259
device_id: str,
6360
) -> None:
6461
super().__init__(coordinator)
6562
self._tracker = tracker
66-
self._entry = entry
6763
self._device_id = device_id
6864
self._attr_unique_id = f"{device_id}_firmware_update"
6965
device = tracker.devices[device_id]
@@ -75,33 +71,38 @@ def __init__(
7571
model=device.target,
7672
)
7773

74+
@property
75+
def _device(self) -> FaikoutDevice | None:
76+
# The tracker drops a device when MQTT stops reporting it, so every read
77+
# goes through here rather than caching the instance from __init__.
78+
return self._tracker.devices.get(self._device_id)
79+
7880
@property
7981
def _latest(self) -> str | None:
80-
device = self._tracker.devices.get(self._device_id)
82+
device = self._device
8183
if device is None:
8284
return None
8385
return self.coordinator.data.get(device.target)
8486

8587
@property
8688
def available(self) -> bool:
87-
device = self._tracker.devices.get(self._device_id)
88-
return super().available and device is not None and self._latest is not None
89+
return super().available and self._device is not None and self._latest is not None
8990

9091
@property
9192
def is_on(self) -> bool | None:
92-
device = self._tracker.devices.get(self._device_id)
93+
device = self._device
9394
latest = self._latest
9495
if device is None or latest is None:
9596
return None
9697
return device.version != latest
9798

9899
@property
99100
def extra_state_attributes(self) -> dict[str, str | None]:
100-
device = self._tracker.devices.get(self._device_id)
101+
device = self._device
101102
return {
102103
"installed_version": device.version if device else None,
103104
"latest_version": self._latest,
104-
"channel": self._entry.options.get(CONF_CHANNEL, self._entry.data[CONF_CHANNEL]),
105+
"channel": self.coordinator.channel.value,
105106
"target": device.target if device else None,
106107
}
107108

custom_components/faikout/config_flow.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
import voluptuous as vol
8+
from homeassistant.components import mqtt
89
from homeassistant.config_entries import (
910
ConfigEntry,
1011
ConfigFlow,
@@ -21,15 +22,26 @@
2122
class FaikoutConfigFlow(ConfigFlow, domain=DOMAIN):
2223
"""Handle the initial configuration."""
2324

25+
# v2 moved the channel from entry.data to entry.options.
26+
VERSION = 2
27+
2428
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
2529
if self._async_current_entries():
2630
return self.async_abort(reason="single_instance_allowed")
27-
if user_input is not None:
28-
return self.async_create_entry(title="Faikout Firmware Update", data=user_input)
29-
schema = vol.Schema(
30-
{vol.Required(CONF_CHANNEL, default=Channel.STABLE.value): vol.In(_CHANNELS)}
31-
)
32-
return self.async_show_form(step_id="user", data_schema=schema)
31+
if user_input is None:
32+
# MQTT is a hard dependency: without it no device is ever discovered.
33+
# Checked here, on the way to the form, so it runs once per flow rather
34+
# than again on submit — it can wait up to 50s while MQTT is still
35+
# setting up — and so the user is turned away before picking a channel.
36+
if not await mqtt.async_wait_for_mqtt_client(self.hass):
37+
return self.async_abort(reason="mqtt_unavailable")
38+
schema = vol.Schema(
39+
{vol.Required(CONF_CHANNEL, default=Channel.STABLE.value): vol.In(_CHANNELS)}
40+
)
41+
return self.async_show_form(step_id="user", data_schema=schema)
42+
# The channel does not establish the connection, so it lives in options and
43+
# stays a single source of truth for the options flow to rewrite.
44+
return self.async_create_entry(title="Faikout Firmware Update", data={}, options=user_input)
3345

3446
@staticmethod
3547
@callback
@@ -43,8 +55,6 @@ class FaikoutOptionsFlow(OptionsFlow):
4355
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
4456
if user_input is not None:
4557
return self.async_create_entry(title="", data=user_input)
46-
current = self.config_entry.options.get(
47-
CONF_CHANNEL, self.config_entry.data.get(CONF_CHANNEL, Channel.STABLE.value)
48-
)
58+
current = self.config_entry.options.get(CONF_CHANNEL, Channel.STABLE.value)
4959
schema = vol.Schema({vol.Required(CONF_CHANNEL, default=current): vol.In(_CHANNELS)})
5060
return self.async_show_form(step_id="init", data_schema=schema)

custom_components/faikout/const.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ class Channel(StrEnum):
1919

2020

2121
MANIFEST_URLS: dict[tuple[str, Channel], str] = {
22-
("Faikout-S3-MINI-N4-R2", Channel.STABLE): (
23-
"https://ota.faikout.uk/Faikin-S3-MINI-N4-R2-manifest.json"
24-
),
22+
# The stable channel is published as the unversioned "Faikout.manifest"; the
23+
# per-target "Faikout-S3-MINI-N4-R2-manifest.json" path 404s on the OTA server.
24+
("Faikout-S3-MINI-N4-R2", Channel.STABLE): "https://ota.faikout.uk/Faikout.manifest",
2525
("Faikout-S3-MINI-N4-R2", Channel.BETA): (
2626
"https://ota.faikout.uk/beta/Faikout-S3-MINI-N4-R2-beta-manifest.json"
2727
),

custom_components/faikout/coordinator.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from .const import DOMAIN, MANIFEST_URLS, UPDATE_INTERVAL, Channel
1111
from .ota.client import FaikoutOtaClient
12-
from .ota.exceptions import FaikoutError
12+
from .ota.exceptions import FaikoutError, FirmwareUnavailableError
1313

1414
_LOGGER = logging.getLogger(__name__)
1515

@@ -21,6 +21,9 @@ def __init__(self, hass: HomeAssistant, client: FaikoutOtaClient, channel: Chann
2121
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)
2222
self._client = client
2323
self.channel = channel
24+
# Targets already warned about as an unreachable-server outage, so the
25+
# warning lands once on the way down and a recovery lands once on the way up.
26+
self._unreachable_targets: set[str] = set()
2427

2528
async def _async_update_data(self) -> dict[str, str]:
2629
# Iterate ALL entries in MANIFEST_URLS for this channel, not just targets seen
@@ -30,19 +33,63 @@ async def _async_update_data(self) -> dict[str, str]:
3033
# transient network errors and permanent data faults are treated as retryable
3134
# here, since a server-side data fault may later be corrected server-side.
3235
result: dict[str, str] = {}
33-
last_error: Exception | None = None
36+
failures: dict[str, FaikoutError] = {}
3437
for (target, channel), url in MANIFEST_URLS.items():
3538
if channel != self.channel:
3639
continue
3740
try:
3841
result[target] = await self._client.async_get_latest_version(url)
39-
except FaikoutError as err:
40-
last_error = err
41-
_LOGGER.debug("Failed to fetch latest version for %s: %s", target, err)
42+
except FaikoutError as error:
43+
# Safe to keep past the except block: only the name is unbound.
44+
failures[target] = error
45+
4246
if not result:
47+
# Every target failed, which DataUpdateCoordinator already logs once on
48+
# the way down and once on recovery. Stay quiet so the outage is not
49+
# reported twice; just carry the state so a later partial failure is
50+
# still judged against it.
51+
self._unreachable_targets = {
52+
target
53+
for target, err in failures.items()
54+
if isinstance(err, FirmwareUnavailableError)
55+
}
56+
last_error = next(iter(failures.values()), None)
57+
if last_error is None:
58+
# The loop never ran: no manifest URL is mapped for this channel, so
59+
# there is no underlying error to report and retrying cannot help.
60+
raise UpdateFailed(
61+
translation_domain=DOMAIN,
62+
translation_key="no_manifest_urls",
63+
translation_placeholders={"channel": self.channel.value},
64+
)
4365
raise UpdateFailed(
4466
translation_domain=DOMAIN,
4567
translation_key="cannot_fetch_version",
4668
translation_placeholders={"error": str(last_error)},
4769
)
70+
71+
# Some targets resolved, so the refresh counts as a success and the base
72+
# class logs nothing. Report the stragglers here or they stay invisible.
73+
for target, err in failures.items():
74+
self._log_target_failed(target, err)
75+
for target in result:
76+
self._log_target_recovered(target)
4877
return result
78+
79+
def _log_target_failed(self, target: str, err: FaikoutError) -> None:
80+
if not isinstance(err, FirmwareUnavailableError):
81+
# A 404, a malformed manifest or a bad image will not clear itself on
82+
# the next poll, and staying quiet is how a dead stable-channel URL
83+
# went unnoticed for two months. Say so every time.
84+
self._unreachable_targets.discard(target)
85+
_LOGGER.warning("Firmware metadata for %s is unusable: %s", target, err)
86+
elif target in self._unreachable_targets:
87+
_LOGGER.debug("OTA server still unreachable for %s: %s", target, err)
88+
else:
89+
self._unreachable_targets.add(target)
90+
_LOGGER.warning("Cannot reach the OTA server for %s: %s", target, err)
91+
92+
def _log_target_recovered(self, target: str) -> None:
93+
if target in self._unreachable_targets:
94+
self._unreachable_targets.discard(target)
95+
_LOGGER.info("OTA server reachable again for %s", target)

custom_components/faikout/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@
1010
"issue_tracker": "https://github.com/steynovich/ha-faikout-firmware/issues",
1111
"quality_scale": "gold",
1212
"requirements": [],
13-
"version": "0.1.0"
13+
"version": "0.2.0"
1414
}

custom_components/faikout/ota/client.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,36 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Awaitable, Callable
6+
from typing import TypeVar
7+
58
import aiohttp
69

7-
from .exceptions import FirmwareFetchError
10+
from .exceptions import FirmwareFetchError, FirmwareUnavailableError
811
from .manifest import parse_manifest
912
from .parser import parse_app_descriptor
1013

1114
HEAD_BYTES = 512
1215
DEFAULT_TIMEOUT = 30.0
1316

17+
_T = TypeVar("_T")
18+
19+
20+
async def _read_head(resp: aiohttp.ClientResponse) -> bytes:
21+
"""Read at most HEAD_BYTES from the response body.
22+
23+
A server that ignores the Range header answers 200 with the whole ~1.5 MB
24+
image, so reading the full body would defeat the point of the ranged request.
25+
StreamReader.read(n) may return fewer than n bytes, hence the loop.
26+
"""
27+
buf = bytearray()
28+
while len(buf) < HEAD_BYTES:
29+
chunk = await resp.content.read(HEAD_BYTES - len(buf))
30+
if not chunk:
31+
break
32+
buf.extend(chunk)
33+
return bytes(buf)
34+
1435

1536
class FaikoutOtaClient:
1637
"""Fetch and parse the latest Faikout firmware version.
@@ -33,22 +54,26 @@ async def async_get_latest_version(self, manifest_url: str) -> str:
3354
head = await self._get_head(app_url)
3455
return parse_app_descriptor(head)
3556

36-
async def _get_text(self, url: str) -> str:
57+
async def _fetch(
58+
self,
59+
url: str,
60+
reader: Callable[[aiohttp.ClientResponse], Awaitable[_T]],
61+
*,
62+
headers: dict[str, str] | None = None,
63+
) -> _T:
3764
try:
38-
async with self._session.get(url, timeout=self._timeout) as resp:
65+
async with self._session.get(url, headers=headers, timeout=self._timeout) as resp:
3966
resp.raise_for_status()
40-
return await resp.text()
41-
except (aiohttp.ClientError, TimeoutError) as err:
67+
return await reader(resp)
68+
except (aiohttp.ClientConnectionError, TimeoutError) as err:
69+
# The host never answered; retryable, and not a sign of a bad URL.
70+
raise FirmwareUnavailableError(f"failed to fetch {url}: {err}") from err
71+
except aiohttp.ClientError as err:
4272
raise FirmwareFetchError(f"failed to fetch {url}: {err}") from err
4373

74+
async def _get_text(self, url: str) -> str:
75+
return await self._fetch(url, lambda resp: resp.text())
76+
4477
async def _get_head(self, url: str) -> bytes:
4578
headers = {"Range": f"bytes=0-{HEAD_BYTES - 1}"}
46-
try:
47-
async with self._session.get(url, headers=headers, timeout=self._timeout) as resp:
48-
resp.raise_for_status()
49-
body = await resp.read()
50-
if resp.status == 206:
51-
return body
52-
return body[:HEAD_BYTES]
53-
except (aiohttp.ClientError, TimeoutError) as err:
54-
raise FirmwareFetchError(f"failed to fetch {url}: {err}") from err
79+
return await self._fetch(url, _read_head, headers=headers)

custom_components/faikout/ota/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,12 @@ class FirmwareParseError(FaikoutError):
1515

1616
class FirmwareFetchError(FaikoutError):
1717
"""A network request to the OTA server failed."""
18+
19+
20+
class FirmwareUnavailableError(FirmwareFetchError):
21+
"""The OTA server could not be reached at all (connection refused, timeout).
22+
23+
Distinct from a plain FirmwareFetchError, which also covers a server that
24+
answered with an error status: a 404 is a broken URL worth failing on, while
25+
an unreachable host is an environment problem worth skipping or retrying.
26+
"""

0 commit comments

Comments
 (0)