|
25 | 25 | from homeassistant.core import HomeAssistant |
26 | 26 | from homeassistant.exceptions import ConfigEntryAuthFailed |
27 | 27 | from homeassistant.helpers.aiohttp_client import async_get_clientsession |
| 28 | +from homeassistant.helpers.json import json_bytes |
28 | 29 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed |
29 | 30 |
|
30 | 31 | from .const import ( |
@@ -109,13 +110,32 @@ async def async_fetch_inventory_payload(hass, username: str, password: str) -> s |
109 | 110 | "Location": "1", |
110 | 111 | } |
111 | 112 |
|
| 113 | + # The password is a query parameter, so it travels in the request URL - and |
| 114 | + # aiohttp's ClientResponseError renders that URL in both str() and repr(). |
| 115 | + # Nothing derived from the failed request may escape this function except a |
| 116 | + # description we build ourselves. |
| 117 | + failure: str | None = None |
| 118 | + |
112 | 119 | try: |
113 | 120 | async with asyncio.timeout(REQUEST_TIMEOUT): |
114 | 121 | async with session.get(BASE_URL, params=params) as response: |
115 | 122 | response.raise_for_status() |
116 | 123 | payload = await response.text() |
| 124 | + except aiohttp.ClientResponseError as err: |
| 125 | + # The status is the diagnostic part and carries nothing sensitive. |
| 126 | + failure = f"HTTP {err.status} from CellarTracker" |
117 | 127 | except aiohttp.ClientError as err: |
118 | | - raise CannotConnect from err |
| 128 | + # Connector and payload errors name the host rather than the query |
| 129 | + # string, but the same rule applies: name the failure, copy nothing. |
| 130 | + failure = type(err).__name__ |
| 131 | + |
| 132 | + if failure is not None: |
| 133 | + # Raised outside the handler deliberately. `raise ... from None` would |
| 134 | + # clear __cause__ but leave the original on __context__, where a |
| 135 | + # traceback would not print it but a diagnostics dump walking the chain |
| 136 | + # still could. Once the except block has exited the exception is no |
| 137 | + # longer being handled, so nothing is attached at all. |
| 138 | + raise CannotConnect(failure) |
119 | 139 |
|
120 | 140 | # An auth failure arrives as HTTP 200 with a marker in the body. |
121 | 141 | if NOT_LOGGED_REPONSE in payload: |
@@ -147,18 +167,38 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): |
147 | 167 | hass, |
148 | 168 | _LOGGER, |
149 | 169 | name=DOMAIN, |
| 170 | + # Mandatory from a future Home Assistant release, and deprecated |
| 171 | + # without it since 2024.11. It also ties the refresh task to the |
| 172 | + # entry, so unloading cancels a poll still in flight. |
| 173 | + config_entry=entry, |
150 | 174 | update_interval=scan_interval, |
151 | 175 | always_update=False, |
152 | 176 | ) |
153 | 177 |
|
154 | 178 | # Consecutive polls that reported an empty cellar after it held stock. |
155 | 179 | self._suspicious_empty_polls = 0 |
156 | 180 |
|
| 181 | + # Replaced wholesale by each refresh, never mutated in place, and only |
| 182 | + # one refresh runs at a time - so the loop can read it without a lock. |
| 183 | + self._inventory_body: bytes = b"[]" |
| 184 | + |
157 | 185 | @property |
158 | 186 | def currency(self) -> str: |
159 | 187 | """Return the configured currency symbol.""" |
160 | 188 | return self._currency |
161 | 189 |
|
| 190 | + @property |
| 191 | + def inventory_body(self) -> bytes: |
| 192 | + """The bottle list as a JSON body, rendered ahead of any request. |
| 193 | +
|
| 194 | + Serialising a large cellar costs real time - tens of milliseconds at a |
| 195 | + few thousand bottles, more on the hardware Home Assistant usually runs |
| 196 | + on - and doing it inside a request handler spends that time on the |
| 197 | + event loop. It is rendered in the executor that already runs the parse |
| 198 | + instead, so the view only ever hands over bytes. |
| 199 | + """ |
| 200 | + return self._inventory_body |
| 201 | + |
162 | 202 | def _process_inventory(self, inventory: list, previous: dict | None = None) -> dict: |
163 | 203 | """Process the raw inventory list into a structured dictionary. |
164 | 204 |
|
@@ -299,4 +339,7 @@ async def _async_update_data(self) -> dict: |
299 | 339 | def _parse_and_process(self, payload: str, previous: dict | None) -> dict: |
300 | 340 | """Parse the tab-separated export, then summarise it. Runs in an executor.""" |
301 | 341 | rows = list(csv.DictReader(io.StringIO(payload), dialect="excel-tab")) |
302 | | - return self._process_inventory(rows, previous=previous) |
| 342 | + result = self._process_inventory(rows, previous=previous) |
| 343 | + # Rendered here, on the executor thread, for the HTTP views to serve. |
| 344 | + self._inventory_body = json_bytes(result["bottles"]) |
| 345 | + return result |
0 commit comments