Skip to content

Commit b7d8189

Browse files
authored
Merge pull request #16 from GuvHas/refactor/tdd-single-account-optimization
Refactor/tdd single account optimization
2 parents d0d0956 + 6500405 commit b7d8189

23 files changed

Lines changed: 1065 additions & 472 deletions

README.md

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ sensors for bottle count and cellar value, plus a searchable, sortable dashboard
3333
The integration is a standard modern custom component: UI config flow, a
3434
`DataUpdateCoordinator` for polling, and entities grouped under a device per account.
3535

36-
**How it fetches data.** It uses the [`cellartracker`](https://pypi.org/project/cellartracker/)
37-
library, which calls CellarTracker's `xlquery.asp` export endpoint and requests the **`Inventory`
38-
table in tab-separated format**. One HTTP request per refresh returns every bottle you own, with
39-
66 columns each. The blocking call runs in an executor with a 60-second bound, and parsing runs
40-
in an executor too, so neither touches Home Assistant's event loop.
36+
**How it fetches data.** One request per refresh to CellarTracker's `xlquery.asp` export
37+
endpoint for the **`Inventory` table in tab-separated format**, returning every bottle you own
38+
with 66 columns each. The request uses Home Assistant's shared `aiohttp` session under a
39+
60-second `asyncio.timeout`, so a hung server is cancelled cleanly rather than parking a
40+
worker thread. Parsing runs in an executor, so the event loop is never blocked. The
41+
[`cellartracker`](https://pypi.org/project/cellartracker/) library supplies the endpoint URL
42+
and error semantics; its own `requests`-based transport sets no timeout and is not used.
4143

4244
**Features**
4345

@@ -50,7 +52,8 @@ in an executor too, so neither touches Home Assistant's event loop.
5052
the integration and is served from it, so there is nothing to copy into `<config>/www`.
5153
- Reauthentication: if your password changes, Home Assistant prompts you to re-enter it rather
5254
than silently failing.
53-
- Multiple CellarTracker accounts, each as its own device.
55+
- One account per installation, enforced by the config flow, so there is no ambiguity
56+
about which cellar an entity or endpoint refers to.
5457
- Upstream error pages are rejected rather than being recorded as a genuine zero, so an outage
5558
cannot punch a hole in your cellar-value history.
5659

@@ -224,11 +227,10 @@ and history all stay as they were.
224227
There is no proactive "change my password now" form. If you would rather not wait for the next
225228
refresh to notice, use **⋮ → Reload** on the integration to trigger one immediately.
226229

227-
### Multiple accounts
230+
### One account per installation
228231

229-
Add the integration more than once. Each account becomes its own device, named after that
230-
account. When more than one is configured, the REST endpoints require you to say which one you
231-
mean (see below); with a single account nothing changes.
232+
The config flow allows a single CellarTracker account. Adding it a second time aborts rather
233+
than creating a duplicate. To switch accounts, delete the existing entry and add it again.
232234

233235
### A note on the refresh interval
234236

@@ -257,18 +259,9 @@ It gives you search across wine name, location and bin; sortable columns; bottle
257259
in your configured currency; links to each wine on CellarTracker; drink-window colouring (green =
258260
ready, red = too early or past); and light/dark theme following your Home Assistant theme.
259261
260-
**With more than one account configured**, name the one you want:
261-
262-
```yaml
263-
type: iframe
264-
url: /cellartracker/cellar.html?entry_id=YOUR_ENTRY_ID
265-
aspect_ratio: 100%
266-
title: Alice's Cellar
267-
```
268-
269-
The entry ID is the last path segment of the URL when you open the integration under
270-
**Settings → Devices & Services**. If you omit it with several accounts configured, the page
271-
tells you which IDs exist rather than guessing.
262+
The card needs no account parameter — one account is supported per installation, so the
263+
endpoints have nothing to disambiguate. A stale `?entry_id=...` left over from a card configured
264+
against v0.0.16 is accepted and ignored, so those cards keep working unchanged.
272265

273266
**Upgrading from before v0.0.16?** You once had to copy the page into `<config>/www` yourself.
274267
That copy still works — `/local/cellar.html` is Home Assistant's own static mount and this change
@@ -471,10 +464,11 @@ standalone browser tab rather than embedded in an iframe card. Use the card desc
471464
[The dashboard](#the-dashboard). The page is deliberately unauthenticated static content; the data
472465
behind it is not, so the API calls it makes need your session.
473466

474-
### The dashboard says several accounts are configured
467+
### Can I add a second CellarTracker account?
475468

476-
Expected with more than one account. Add `?entry_id=...` to the iframe URL. The error message
477-
lists the available IDs.
469+
No. One account per installation is enforced: a second attempt aborts with "CellarTracker
470+
is already configured". Remove the existing entry under **Settings → Devices & Services**
471+
first if you want to switch accounts.
478472

479473
### Passing `?token=` in the dashboard URL
480474

@@ -541,12 +535,34 @@ Tags are bare version numbers with no `v` prefix, optionally with a single-lette
541535
already exists is safe: the workflow checks that tag out and validates it, rather than validating
542536
the branch and publishing the tag.
543537

544-
### Known limitation
538+
### Why the library's transport is not used
539+
540+
The `cellartracker` library calls `requests.get(url, params)` with no `timeout=`
541+
([`api.py`](https://github.com/mathroule/cellartracker/blob/master/cellartracker/api.py)), so the
542+
socket has no deadline. Running that on an executor thread means an application-level timeout can
543+
stop Home Assistant *waiting*, but cannot interrupt the worker: `concurrent.futures` has no way to
544+
cancel a thread that is already running, so it stays in `recv()` until the OS gives up. For a
545+
server that accepts a connection and then never replies, that is the TCP keepalive interval —
546+
7200 seconds by default — with the account password sitting in the thread's stack frame.
547+
548+
So the integration does its own HTTP with Home Assistant's shared `aiohttp` session, where
549+
cancellation genuinely cancels and no thread is involved. The library still supplies the endpoint
550+
URL, the not-logged-in marker, the table and format enums, and the exception types: it owns the
551+
contract, just not the transport.
552+
553+
**Possible future contribution.** Adding `timeout=` to `cellartracker`'s `api.py` would fix this
554+
at the root for every consumer — roughly:
555+
556+
```python
557+
DEFAULT_TIMEOUT = 60
558+
559+
def execute(self, url=BASE_URL, params={}, timeout=DEFAULT_TIMEOUT):
560+
...
561+
reponse = requests.get(url, params, timeout=timeout)
562+
```
545563

546-
The upstream `cellartracker` library calls `requests.get()` without a `timeout`. This integration
547-
bounds how long Home Assistant waits, so a hung request fails cleanly and retries on schedule, but
548-
it cannot cancel a worker thread already blocked in the library. A fully robust fix needs
549-
`timeout=` upstream.
564+
That is worth submitting upstream if anyone feels like it, but **this integration does not depend
565+
on it** — it no longer calls that code path at all. Noted here so the reasoning is not lost.
550566

551567
---
552568

custom_components/card.yaml

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,5 @@ title: My Wine Collection
1313
# deprecated - a long-lived token is full-account and never expires, so a URL
1414
# carrying one leaks it into history and server logs.
1515
#
16-
# With more than one CellarTracker account configured, name the account
17-
# explicitly - the API will not guess which cellar to show:
18-
#
19-
# url: /cellartracker/cellar.html?entry_id=YOUR_ENTRY_ID
20-
#
21-
# The entry id is the last path segment of the integration's URL under
22-
# Settings / Devices & Services, and is also listed in the error message the
23-
# dashboard shows when the account is ambiguous.
16+
# One CellarTracker account is supported per Home Assistant installation,
17+
# so the card needs no account parameter.

custom_components/cellar_tracker/cellar_data.py

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
import asyncio
2+
import csv
23
import hashlib
4+
import io
35
import logging
46
from collections import defaultdict
57
from datetime import timedelta
68

7-
# Imported at module scope: Home Assistant imports integration modules in an
8-
# executor, so the file I/O happens off the event loop. Importing inside
9-
# __init__ runs it *on* the loop and trips HA's blocking-call detector.
9+
# The library still owns the endpoint contract - its URL, the marker that
10+
# signals a rejected login, and the exception types - but not the transport:
11+
# its requests.get() sets no timeout. See _fetch_payload.
1012
#
1113
# The exception *types* are also the only reliable way to classify failures:
1214
# the library raises them bare (`raise AuthenticationError`), so `str(err)` is
1315
# always the empty string and message sniffing can never match.
14-
from cellartracker import cellartracker
16+
#
17+
# Imported at module scope: Home Assistant imports integration modules in an
18+
# executor, so this file I/O happens off the event loop.
19+
import aiohttp
20+
from cellartracker.const import BASE_URL, NOT_LOGGED_REPONSE
21+
from cellartracker.enum import CellarTrackerFormat, CellarTrackerTable
1522
from cellartracker.errors import AuthenticationError, CannotConnect
1623
from homeassistant.config_entries import ConfigEntry
1724
from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
1825
from homeassistant.core import HomeAssistant
1926
from homeassistant.exceptions import ConfigEntryAuthFailed
27+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
2028
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
2129

2230
from .const import (
@@ -34,12 +42,14 @@
3442
# the first such poll to protect the statistics, then believe a repeat.
3543
TOLERATED_SUSPICIOUS_EMPTY_POLLS = 1
3644

37-
# The library calls requests.get() without a timeout, so a server that accepts
38-
# the connection and never replies holds the worker until TCP keepalive expires
39-
# (~2h by default). This bounds what Home Assistant waits for; it cannot cancel
40-
# the blocked thread, which needs timeout= upstream in cellartracker.
45+
# Enforced by asyncio.timeout around an aiohttp request, so it genuinely
46+
# cancels. The library's own requests.get() sets no timeout, which is why the
47+
# transport is no longer routed through it.
4148
REQUEST_TIMEOUT = 60
4249

50+
TABLE_INVENTORY = CellarTrackerTable.Inventory.value
51+
FORMAT_TAB = CellarTrackerFormat.tab.value
52+
4353
# Columns that identify a physical bottle. Volatile columns are deliberately
4454
# excluded: Valuation moves whenever CellarTracker re-prices a wine, and an id
4555
# that changed on every re-pricing would be useless to anything keying on it.
@@ -74,6 +84,46 @@ def _row_fingerprint(bottle: dict) -> str:
7484
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
7585

7686

87+
async def async_fetch_inventory_payload(hass, username: str, password: str) -> str:
88+
"""Fetch the raw inventory export for an account.
89+
90+
Shared by the coordinator and by the config flow's credential check, so the
91+
two cannot drift apart on transport or on what counts as an auth failure.
92+
93+
Home Assistant's shared aiohttp session replaces the library's
94+
``requests.get()``, which sets no timeout: an ``asyncio.timeout`` around an
95+
executor job bounds the wait but cannot interrupt a worker already blocked
96+
in ``recv()``. Cancelling an aiohttp request actually cancels it, and no
97+
thread is involved.
98+
99+
Raises:
100+
AuthenticationError: CellarTracker rejected the credentials.
101+
CannotConnect: the export could not be retrieved.
102+
"""
103+
session = async_get_clientsession(hass)
104+
params = {
105+
"User": username,
106+
"Password": password,
107+
"Table": TABLE_INVENTORY,
108+
"Format": FORMAT_TAB,
109+
"Location": "1",
110+
}
111+
112+
try:
113+
async with asyncio.timeout(REQUEST_TIMEOUT):
114+
async with session.get(BASE_URL, params=params) as response:
115+
response.raise_for_status()
116+
payload = await response.text()
117+
except aiohttp.ClientError as err:
118+
raise CannotConnect from err
119+
120+
# An auth failure arrives as HTTP 200 with a marker in the body.
121+
if NOT_LOGGED_REPONSE in payload:
122+
raise AuthenticationError
123+
124+
return payload
125+
126+
77127
class WineCellarData(DataUpdateCoordinator):
78128
"""Fetch and process CellarTracker inventory data."""
79129

@@ -101,8 +151,6 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
101151
always_update=False,
102152
)
103153

104-
self._client = cellartracker.CellarTracker(self._username, self._password)
105-
106154
# Consecutive polls that reported an empty cellar after it held stock.
107155
self._suspicious_empty_polls = 0
108156

@@ -219,13 +267,16 @@ def _process_inventory(self, inventory: list, previous: dict | None = None) -> d
219267
"bottles": processed_bottles,
220268
}
221269

270+
async def _fetch_payload(self) -> str:
271+
"""Fetch the raw inventory export for this entry's account."""
272+
return await async_fetch_inventory_payload(
273+
self._hass, self._username, self._password
274+
)
275+
222276
async def _async_update_data(self) -> dict:
223277
"""Fetch inventory from CellarTracker."""
224278
try:
225-
async with asyncio.timeout(REQUEST_TIMEOUT):
226-
inventory_list = await self._hass.async_add_executor_job(
227-
self._client.get_inventory
228-
)
279+
payload = await self._fetch_payload()
229280
except AuthenticationError as err:
230281
# Surfaces as a reauth flow (see async_step_reauth in config_flow).
231282
raise ConfigEntryAuthFailed(
@@ -238,9 +289,14 @@ async def _async_update_data(self) -> dict:
238289
_LOGGER.exception("Unexpected error fetching CellarTracker inventory")
239290
raise UpdateFailed(f"Unexpected CellarTracker error: {err!r}") from err
240291

241-
# Parsing a large cellar means hashing every row and copying every dict,
242-
# so keep it off the event loop. self.data is the last successful
243-
# result, or None on the first poll.
292+
# I/O no longer needs a thread, but parsing still does: a large cellar
293+
# means splitting 66 columns per row, hashing each one and copying every
294+
# dict. self.data is the last successful result, or None on first poll.
244295
return await self._hass.async_add_executor_job(
245-
self._process_inventory, inventory_list, self.data
296+
self._parse_and_process, payload, self.data
246297
)
298+
299+
def _parse_and_process(self, payload: str, previous: dict | None) -> dict:
300+
"""Parse the tab-separated export, then summarise it. Runs in an executor."""
301+
rows = list(csv.DictReader(io.StringIO(payload), dialect="excel-tab"))
302+
return self._process_inventory(rows, previous=previous)

custom_components/cellar_tracker/config_flow.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66

77
# Classify failures by exception type: the library raises these bare, so
88
# `str(err)` is always "" and message sniffing can never match.
9-
from cellartracker import cellartracker
109
from cellartracker.errors import AuthenticationError, CannotConnect
1110
from homeassistant import config_entries
1211
from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
1312
from homeassistant.core import callback
1413

14+
from .cellar_data import async_fetch_inventory_payload
1515
from .const import (
1616
CONF_CURRENCY,
1717
CURRENCY_OPTIONS,
@@ -38,16 +38,6 @@
3838
REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
3939

4040

41-
def _validate_credentials(username: str, password: str) -> None:
42-
"""Authenticate against CellarTracker. Blocking - run in an executor.
43-
44-
Raises:
45-
AuthenticationError: the username/password pair was rejected.
46-
CannotConnect: CellarTracker was unreachable.
47-
"""
48-
cellartracker.CellarTracker(username, password).get_inventory()
49-
50-
5141
class CellarTrackerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
5242
"""Handle a config flow for CellarTracker."""
5343

@@ -56,9 +46,9 @@ class CellarTrackerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
5646
async def _async_check_credentials(self, username: str, password: str) -> dict:
5747
"""Return a form-errors dict; empty means the credentials are valid."""
5848
try:
59-
await self.hass.async_add_executor_job(
60-
_validate_credentials, username, password
61-
)
49+
# The same non-blocking fetch the coordinator uses, so a hung
50+
# server cannot stall setup on a worker thread.
51+
await async_fetch_inventory_payload(self.hass, username, password)
6252
except AuthenticationError:
6353
_LOGGER.warning("CellarTracker rejected the credentials for %s", username)
6454
return {"base": "invalid_auth"}
@@ -72,6 +62,13 @@ async def _async_check_credentials(self, username: str, password: str) -> dict:
7262

7363
async def async_step_user(self, user_input=None):
7464
"""Handle the initial user step."""
65+
# One CellarTracker account per installation. Checked before anything
66+
# else so a duplicate is refused without a round trip to CellarTracker,
67+
# and checked via the entry list rather than the unique id alone so
68+
# that legacy entries - keyed on the username - also block.
69+
if self._async_current_entries():
70+
return self.async_abort(reason="single_instance_allowed")
71+
7572
errors = {}
7673

7774
if user_input is not None:
@@ -83,7 +80,9 @@ async def async_step_user(self, user_input=None):
8380
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
8481
)
8582
if not errors:
86-
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
83+
# The domain, not the username: a second entry is a duplicate
84+
# whichever account it names.
85+
await self.async_set_unique_id(DOMAIN)
8786
self._abort_if_unique_id_configured()
8887
return self.async_create_entry(
8988
title=user_input[CONF_USERNAME], data=user_input
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
{
22
"domain": "cellar_tracker",
33
"name": "CellarTracker",
4-
"codeowners": ["@GuvHas"],
4+
"codeowners": [
5+
"@GuvHas"
6+
],
57
"config_flow": true,
6-
"dependencies": ["http"],
8+
"dependencies": [
9+
"http"
10+
],
711
"documentation": "https://github.com/GuvHas/cellartracker",
812
"integration_type": "service",
913
"iot_class": "cloud_polling",
1014
"issue_tracker": "https://github.com/GuvHas/cellartracker/issues",
11-
"loggers": ["cellartracker"],
12-
"requirements": ["cellartracker==1.1.1"],
15+
"requirements": [
16+
"cellartracker==1.1.1"
17+
],
1318
"version": "0.0.16"
1419
}

custom_components/cellar_tracker/strings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
},
2727
"abort": {
2828
"already_configured": "This CellarTracker account is already configured.",
29-
"reauth_successful": "CellarTracker was re-authenticated successfully."
29+
"reauth_successful": "CellarTracker was re-authenticated successfully.",
30+
"single_instance_allowed": "CellarTracker is already configured. Only one account is supported per Home Assistant installation."
3031
}
3132
},
3233
"options": {

custom_components/cellar_tracker/translations/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
},
2727
"abort": {
2828
"already_configured": "This CellarTracker account is already configured.",
29-
"reauth_successful": "CellarTracker was re-authenticated successfully."
29+
"reauth_successful": "CellarTracker was re-authenticated successfully.",
30+
"single_instance_allowed": "CellarTracker is already configured. Only one account is supported per Home Assistant installation."
3031
}
3132
},
3233
"options": {

0 commit comments

Comments
 (0)