diff --git a/custom_components/uster_waste/__init__.py b/custom_components/uster_waste/__init__.py index 84a10ae..dd005ea 100644 --- a/custom_components/uster_waste/__init__.py +++ b/custom_components/uster_waste/__init__.py @@ -3,19 +3,37 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.const import Platform -from .const import DOMAIN - -PLATFORMS = [Platform.SENSOR, Platform.BUTTON] +from .const import DOMAIN, MANUAL_REFRESH_SERVICE +from .sensor import UsterWasteDataUpdateCoordinator +PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Uster Waste from a config entry.""" hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data + # Set up coordinator *once per entry* + coordinator = UsterWasteDataUpdateCoordinator(hass, entry) + hass.data[DOMAIN][entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Register manual refresh service + async def handle_refresh(call): + entry_id = call.data.get("entry_id", entry.entry_id) + coord = hass.data[DOMAIN].get(entry_id) + if coord: + await coord.async_request_refresh() + else: + hass.components.persistent_notification.create( + "Uster Waste: No active entry found. Reload the integration.", + title="Uster Waste Error" + ) + + hass.services.async_register(DOMAIN, MANUAL_REFRESH_SERVICE, handle_refresh) return True - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + """Unload config entry.""" + hass.services.async_remove(DOMAIN, MANUAL_REFRESH_SERVICE) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) \ No newline at end of file diff --git a/custom_components/uster_waste/__pycache__/__init__.cpython-313.pyc b/custom_components/uster_waste/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..efc90eb Binary files /dev/null and b/custom_components/uster_waste/__pycache__/__init__.cpython-313.pyc differ diff --git a/custom_components/uster_waste/__pycache__/button.cpython-313.pyc b/custom_components/uster_waste/__pycache__/button.cpython-313.pyc new file mode 100644 index 0000000..c1be01d Binary files /dev/null and b/custom_components/uster_waste/__pycache__/button.cpython-313.pyc differ diff --git a/custom_components/uster_waste/__pycache__/config_flow.cpython-313.pyc b/custom_components/uster_waste/__pycache__/config_flow.cpython-313.pyc new file mode 100644 index 0000000..ad2d10a Binary files /dev/null and b/custom_components/uster_waste/__pycache__/config_flow.cpython-313.pyc differ diff --git a/custom_components/uster_waste/__pycache__/const.cpython-313.pyc b/custom_components/uster_waste/__pycache__/const.cpython-313.pyc new file mode 100644 index 0000000..b95b638 Binary files /dev/null and b/custom_components/uster_waste/__pycache__/const.cpython-313.pyc differ diff --git a/custom_components/uster_waste/__pycache__/sensor.cpython-313.pyc b/custom_components/uster_waste/__pycache__/sensor.cpython-313.pyc index 9746848..377b7d6 100644 Binary files a/custom_components/uster_waste/__pycache__/sensor.cpython-313.pyc and b/custom_components/uster_waste/__pycache__/sensor.cpython-313.pyc differ diff --git a/custom_components/uster_waste/config_flow.py b/custom_components/uster_waste/config_flow.py index 536c013..8c339d3 100644 --- a/custom_components/uster_waste/config_flow.py +++ b/custom_components/uster_waste/config_flow.py @@ -1,48 +1,37 @@ -"""Config flow for Uster Waste integration.""" +"""Config flow for Uster Waste.""" import voluptuous as vol from homeassistant import config_entries -from homeassistant.data_entry_flow import FlowResult from homeassistant.const import CONF_NAME - from .const import DOMAIN -# Suggested defaults (update these as needed) -DEFAULT_NAME = "Uster Waste Schedule" - DATA_SCHEMA = vol.Schema({ - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required("token", default=""): str, - vol.Required("id", default=""): str, + vol.Required(CONF_NAME, default="Uster Waste"): str, + vol.Required("token", default="").strip(), + vol.Required("id", default="").strip(), }) -class UsterWasteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Uster Waste.""" - VERSION = 1 - async def async_step_user( - self, user_input: dict[str, str] | None = None - ) -> FlowResult: - """Handle the initial step.""" + async def async_step_user(self, user_input=None): if user_input is None: return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) token = user_input["token"].strip() waste_id = user_input["id"].strip() - if not token or not waste_id: return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - errors={"base": "missing_params"}, + step_id="user", data_schema=DATA_SCHEMA, + errors={"base": "missing_params"} ) - # Build the URL (store config, not raw URL) - config = { - CONF_NAME: user_input[CONF_NAME], - "token": token, - "id": waste_id, - } - - return self.async_create_entry(title=user_input[CONF_NAME], data=config) + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_NAME: user_input[CONF_NAME], + "token": token, + "id": waste_id, + } + ) \ No newline at end of file diff --git a/custom_components/uster_waste/const.py b/custom_components/uster_waste/const.py index 35a4c76..1bf2be2 100644 --- a/custom_components/uster_waste/const.py +++ b/custom_components/uster_waste/const.py @@ -11,3 +11,5 @@ ATTR_ERROR = "error" # UI Labels MANUAL_REFRESH = "manual_refresh" + +MANUAL_REFRESH_SERVICE = "refresh_waste_schedule" \ No newline at end of file diff --git a/custom_components/uster_waste/sensor.py b/custom_components/uster_waste/sensor.py index ecdc640..bbddecf 100644 --- a/custom_components/uster_waste/sensor.py +++ b/custom_components/uster_waste/sensor.py @@ -1,209 +1,179 @@ -"""Sensor platform for Uster Waste.""" -import asyncio +"""Uster Waste Sensor.""" import logging +import re +import asyncio from datetime import datetime, timedelta from typing import Optional import aiohttp from bs4 import BeautifulSoup + from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import ( - DOMAIN, - ATTR_NEXT_COLLECTION, - ATTR_DATE, - ATTR_TYPE, - ATTR_DAYS_UNTIL, - ATTR_ENTRIES, - ATTR_ERROR, - MANUAL_REFRESH, -) +from .const import DOMAIN, MANUAL_REFRESH_SERVICE _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(days=1) +# ✅ CORRECTED URL FORMAT (no %5B, no extra encoded brackets) +BASE_URL = "https://www.uster.ch/abfallstrassenabschnitt" -# Swiss date helpers -MONTH_MAP = { +# For Swiss date parsing +MONTHS = { "Jan": "01", "Feb": "02", "Mrz": "03", "Mär": "03", "Apr": "04", "Mai": "05", "Jun": "06", "Jul": "07", "Aug": "08", "Sep": "09", "Okt": "10", "Nov": "11", "Dez": "12" } -def _parse_date(date_str: str) -> Optional[datetime]: - """Convert Swiss date string (e.g., '24.10.2023' or '24. Okt. 2023') to datetime.""" +def parse_swiss_date(date_str: str) -> Optional[datetime]: + """Parse Swiss date like '24. Okt. 2023' or '24.10.2023'.""" date_str = date_str.strip() - # Normalize Swiss month abbreviations - for key, value in MONTH_MAP.items(): - date_str = date_str.replace(key, value) - # Try formats: dd.mm.yyyy, d.m.yy + if not date_str: + return None + # Replace Swiss months (e.g., "Okt" → "10") + for swiss, num in MONTHS.items(): + date_str = date_str.replace(swiss, num) + # Clean extra spaces/dots + date_str = re.sub(r'\.\s*\.', '.', date_str) + # Try multiple formats for fmt in ["%d.%m.%Y", "%d.%m.%y"]: try: return datetime.strptime(date_str, fmt) except ValueError: continue - _LOGGER.warning(f"Could not parse date: '{date_str}'") + _LOGGER.warning(f"Failed to parse date: {date_str}") return None +class UsterWasteDataUpdateCoordinator(DataUpdateCoordinator[dict]): + """Fetch and cache Uster waste data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + self.hass = hass + self.entry = entry + self.token = entry.data["token"] + self.id = entry.data["id"] + self.name = entry.data.get("name", "Uster Waste") + # ✅ CORRECT URL: + self.url = f"{BASE_URL}?token={self.token}&strassenabschnittId={self.id}" + super().__init__(hass, _LOGGER, name="uster_waste", update_interval=timedelta(days=1)) + + async def _async_update_data(self) -> dict: + """Fetch the latest data from Uster.""" + session = async_get_clientsession(self.hass) + try: + async with session.get(self.url, timeout=10) as resp: + if resp.status != 200: + raise UpdateFailed( + f"HTTP {resp.status} — check token & ID (token may be expired)." + "Get a fresh URL from https://www.uster.ch/abfallstrassenabschnitt" + ) + html = await resp.text() + except aiohttp.ClientError as e: + raise UpdateFailed(f"Network error: {e}") from e + + # Parse HTML + soup = BeautifulSoup(html, "html.parser") + + # Find the table (robust: class="table table-striped" or any