11import asyncio
2+ import csv
23import hashlib
4+ import io
35import logging
46from collections import defaultdict
57from 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
1522from cellartracker .errors import AuthenticationError , CannotConnect
1623from homeassistant .config_entries import ConfigEntry
1724from homeassistant .const import CONF_PASSWORD , CONF_SCAN_INTERVAL , CONF_USERNAME
1825from homeassistant .core import HomeAssistant
1926from homeassistant .exceptions import ConfigEntryAuthFailed
27+ from homeassistant .helpers .aiohttp_client import async_get_clientsession
2028from homeassistant .helpers .update_coordinator import DataUpdateCoordinator , UpdateFailed
2129
2230from .const import (
3442# the first such poll to protect the statistics, then believe a repeat.
3543TOLERATED_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.
4148REQUEST_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+
77127class 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 )
0 commit comments