Skip to content
Open
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
61 changes: 51 additions & 10 deletions custom_components/cellar_tracker/cellar_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,72 @@
import hashlib
from datetime import timedelta

from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN, CONF_CURRENCY, DEFAULT_CURRENCY
from .const import (
CONF_CURRENCY,
DEFAULT_CURRENCY,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
normalize_currency,
)

_LOGGER = logging.getLogger(__name__)
AUTH_ERROR_MARKERS = ("auth", "unauthorized", "invalid", "forbidden", "401")
CONNECTIVITY_ERROR_MARKERS = (
"timeout",
"timed out",
"connection",
"tempor",
"dns",
"ssl",
"503",
)


def _is_auth_error(err: Exception) -> bool:
"""Best-effort check for authentication failures."""
message = str(err).lower()
return any(marker in message for marker in AUTH_ERROR_MARKERS)


def _is_connectivity_error(err: Exception) -> bool:
"""Best-effort check for temporary connectivity issues."""
if isinstance(err, (OSError, TimeoutError, ValueError)):
return True

message = str(err).lower()
return any(marker in message for marker in CONNECTIVITY_ERROR_MARKERS)

class WineCellarData(DataUpdateCoordinator):
"""Fetch and process CellarTracker inventory data."""

def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
"""Initialize the data coordinator."""
self._hass = hass
self._username = entry.data["username"]
self._password = entry.data["password"]
self._currency = entry.options.get(
CONF_CURRENCY, entry.data.get(CONF_CURRENCY, DEFAULT_CURRENCY)
self._username = entry.data[CONF_USERNAME]
self._password = entry.data[CONF_PASSWORD]
self._currency = normalize_currency(
entry.options.get(CONF_CURRENCY, entry.data.get(CONF_CURRENCY, DEFAULT_CURRENCY))
)

scan_interval = timedelta(
seconds=entry.options.get("scan_interval", entry.data.get("scan_interval", 3600))
seconds=entry.options.get(
CONF_SCAN_INTERVAL,
entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL),
)
)

super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=scan_interval,
always_update=False,
)

# Using the standard library as requested
Expand Down Expand Up @@ -96,7 +133,11 @@ async def _async_update_data(self) -> dict:
# Use the library to fetch data
inventory_list = await self._hass.async_add_executor_job(self._client.get_inventory)
return self._process_inventory(inventory_list)

except Exception as e:
_LOGGER.error("Error communicating with CellarTracker API: %s", e)
raise UpdateFailed(f"Error communicating with CellarTracker API: {e}")
except Exception as err: # Third-party library raises broad exception types
if _is_auth_error(err):
raise ConfigEntryAuthFailed("Authentication failed for CellarTracker") from err
if _is_connectivity_error(err):
_LOGGER.warning("Temporary communication error with CellarTracker API: %s", err)
else:
_LOGGER.error("Error communicating with CellarTracker API: %s", err)
raise UpdateFailed(f"Error communicating with CellarTracker API: {err}") from err
92 changes: 77 additions & 15 deletions custom_components/cellar_tracker/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,61 @@
# config_flow.py

import logging

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL
from homeassistant.core import callback

from .const import DOMAIN, CONF_CURRENCY, DEFAULT_CURRENCY, CURRENCY_OPTIONS
from .const import (
CONF_CURRENCY,
CURRENCY_OPTIONS,
DEFAULT_CURRENCY,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
MIN_SCAN_INTERVAL,
normalize_currency,
)

_LOGGER = logging.getLogger(__name__)
AUTH_ERROR_MARKERS = ("auth", "unauthorized", "invalid", "forbidden", "401")

DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_SCAN_INTERVAL, default=3600): vol.All(vol.Coerce(int), vol.Range(min=300)),
vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All(
vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL)
),
vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): vol.In(CURRENCY_OPTIONS),
}
)


def _is_auth_error(err: Exception) -> bool:
"""Best-effort check for authentication failures from the third-party library."""
message = str(err).lower()
return any(marker in message for marker in AUTH_ERROR_MARKERS)


def _is_connectivity_error(err: Exception) -> bool:
"""Best-effort check for temporary connectivity failures."""
if isinstance(err, (OSError, TimeoutError, ValueError)):
return True

message = str(err).lower()
connectivity_markers = (
"timeout",
"timed out",
"connection",
"tempor",
"dns",
"ssl",
"503",
)
return any(marker in message for marker in connectivity_markers)

class CellarTrackerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for CellarTracker."""

Expand All @@ -31,25 +67,44 @@ async def async_step_user(self, user_input=None):

if user_input is not None:
try:
user_input[CONF_CURRENCY] = normalize_currency(
user_input.get(CONF_CURRENCY, DEFAULT_CURRENCY)
)

def _validate_credentials():
"""Validate credentials by authenticating and fetching data."""
from cellartracker import cellartracker

client = cellartracker.CellarTracker(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])

client = cellartracker.CellarTracker(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
client.get_inventory()
return True

await self.hass.async_add_executor_job(_validate_credentials)

await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()

return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input)

except Exception:
_LOGGER.exception("Failed to connect to CellarTracker with provided credentials")
errors["base"] = "auth"

except Exception as err: # Third-party library raises broad exception types
if _is_auth_error(err):
_LOGGER.warning(
"Authentication failed for CellarTracker user %s",
user_input[CONF_USERNAME],
)
errors["base"] = "auth"
elif _is_connectivity_error(err):
_LOGGER.warning(
"Network error while validating CellarTracker credentials: %s",
err,
)
errors["base"] = "cannot_connect"
else:
_LOGGER.exception("Unexpected error validating CellarTracker credentials")
errors["base"] = "unknown"

return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
Expand All @@ -70,20 +125,27 @@ class CellarTrackerOptionsFlowHandler(config_entries.OptionsFlow):
async def async_step_init(self, user_input=None):
"""Manage the options."""
if user_input is not None:
user_input[CONF_CURRENCY] = normalize_currency(
user_input.get(CONF_CURRENCY, DEFAULT_CURRENCY)
)
return self.async_create_entry(title="", data=user_input)

current_scan_interval = self.config_entry.options.get(
CONF_SCAN_INTERVAL, self.config_entry.data.get(CONF_SCAN_INTERVAL, 3600)
CONF_SCAN_INTERVAL,
self.config_entry.data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL),
)
current_currency = self.config_entry.options.get(
CONF_CURRENCY, self.config_entry.data.get(CONF_CURRENCY, DEFAULT_CURRENCY)
)
current_currency = normalize_currency(current_currency)

options_schema = vol.Schema(
{
vol.Optional(CONF_SCAN_INTERVAL, default=current_scan_interval): vol.All(vol.Coerce(int), vol.Range(min=300)),
vol.Optional(CONF_SCAN_INTERVAL, default=current_scan_interval): vol.All(
vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL)
),
vol.Optional(CONF_CURRENCY, default=current_currency): vol.In(CURRENCY_OPTIONS),
}
)

return self.async_show_form(step_id="init", data_schema=options_schema)
return self.async_show_form(step_id="init", data_schema=options_schema)
70 changes: 58 additions & 12 deletions custom_components/cellar_tracker/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,65 @@
PLATFORMS = ["sensor"]

CONF_CURRENCY = "currency"
DEFAULT_CURRENCY = "$"
DEFAULT_CURRENCY = "USD"
DEFAULT_SCAN_INTERVAL = 21600
MIN_SCAN_INTERVAL = 900

CURRENCY_OPTIONS = {
"$": "USD ($)",
"": "EUR (€)",
"£": "GBP (£)",
"USD": "USD ($)",
"EUR": "EUR (€)",
"GBP": "GBP (£)",
"CHF": "CHF",
"CA$": "CAD (CA$)",
"AU$": "AUD (AU$)",
"¥": "JPY (¥)",
"kr": "SEK/NOK/DKK (kr)",
"R$": "BRL (R$)",
"₹": "INR (₹)",
"CAD": "CAD (CA$)",
"AUD": "AUD (AU$)",
"JPY": "JPY (¥)",
"SEK": "SEK (kr)",
"NOK": "NOK (kr)",
"DKK": "DKK (kr)",
"BRL": "BRL (R$)",
"INR": "INR (₹)",
"ZAR": "ZAR",
"NZ$": "NZD (NZ$)",
}
"NZD": "NZD (NZ$)",
}

CURRENCY_SYMBOLS = {
"USD": "$",
"EUR": "€",
"GBP": "£",
"CHF": "CHF",
"CAD": "CA$",
"AUD": "AU$",
"JPY": "¥",
"SEK": "kr",
"NOK": "kr",
"DKK": "kr",
"BRL": "R$",
"INR": "₹",
"ZAR": "ZAR",
"NZD": "NZ$",
}

LEGACY_CURRENCY_MAP = {
"$": "USD",
"€": "EUR",
"£": "GBP",
"CA$": "CAD",
"AU$": "AUD",
"¥": "JPY",
"kr": "SEK",
"R$": "BRL",
"₹": "INR",
"NZ$": "NZD",
}


def normalize_currency(value: str | None) -> str:
"""Normalize currency values to ISO 4217-style codes used by HA monetary sensors."""
if not value:
return DEFAULT_CURRENCY

value_upper = value.upper()
if value_upper in CURRENCY_OPTIONS:
return value_upper

return LEGACY_CURRENCY_MAP.get(value, DEFAULT_CURRENCY)
10 changes: 6 additions & 4 deletions custom_components/cellar_tracker/sensor.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN, CONF_CURRENCY, DEFAULT_CURRENCY
from .const import CONF_CURRENCY, DEFAULT_CURRENCY, DOMAIN, normalize_currency
from .cellar_data import WineCellarData

async def async_setup_entry(
Expand All @@ -15,8 +16,8 @@ async def async_setup_entry(
"""Set up the sensor platform."""
coordinator: WineCellarData = hass.data[DOMAIN][entry.entry_id]

currency = entry.options.get(
CONF_CURRENCY, entry.data.get(CONF_CURRENCY, DEFAULT_CURRENCY)
currency = normalize_currency(
entry.options.get(CONF_CURRENCY, entry.data.get(CONF_CURRENCY, DEFAULT_CURRENCY))
)

device_info = {
Expand Down Expand Up @@ -78,6 +79,7 @@ def __init__(self, coordinator, device_info, entry_id):
self._attr_unique_id = f"{entry_id}_inventory_status"
self._attr_icon = "mdi:api"
self._attr_device_info = device_info
self._attr_entity_category = EntityCategory.DIAGNOSTIC

@property
def native_value(self):
Expand All @@ -89,4 +91,4 @@ def extra_state_attributes(self):
return {
"api_endpoint": "/api/cellartracker/inventory",
"info": "Configure Flex Table Card with 'url: /api/cellartracker/inventory'"
}
}
Loading