From db3e4a3cceaa54879a48f4a513f95b7d073f37aa Mon Sep 17 00:00:00 2001 From: Rolf Date: Wed, 4 Feb 2026 20:33:00 +0100 Subject: [PATCH] new version --- README.md | 2 +- __init__.py | 45 +++++ __pycache__/button.cpython-313.pyc | Bin 0 -> 2916 bytes __pycache__/config_flow.cpython-313.pyc | Bin 0 -> 2023 bytes __pycache__/const.cpython-313.pyc | Bin 0 -> 436 bytes __pycache__/sensor.cpython-313.pyc | Bin 0 -> 10862 bytes button.py | 66 +++++++ config_flow.py | 48 +++++ const.py | 13 ++ manifest.json | 17 ++ sensor.py | 237 ++++++++++++++++++++++++ 11 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 __init__.py create mode 100644 __pycache__/button.cpython-313.pyc create mode 100644 __pycache__/config_flow.cpython-313.pyc create mode 100644 __pycache__/const.cpython-313.pyc create mode 100644 __pycache__/sensor.cpython-313.pyc create mode 100644 button.py create mode 100644 config_flow.py create mode 100644 const.py create mode 100644 manifest.json create mode 100644 sensor.py diff --git a/README.md b/README.md index 493c359..8485e26 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# Home Assistant Plugin - Uster Waste Management Calendar +# uster_waste \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..7506e2d --- /dev/null +++ b/__init__.py @@ -0,0 +1,45 @@ +"""Uster Waste integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.const import Platform, SERVICE_UPDATE_ENTITY + +from .const import DOMAIN + +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 + + # Register manual refresh service + hass.services.async_register( + DOMAIN, + entry.data.get("name", "uster_waste"), + async_handle_manual_refresh + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +async def async_handle_manual_refresh(service): + """Handle manual refresh service call.""" + entry_id = service.data.get("entry_id") + if not entry_id or entry_id not in hass.data[DOMAIN]: + return + + # Trigger update for all sensors under this entry + await hass.services.async_call( + "homeassistant", "update_entity", + {"entity_id": [f"sensor.uster_waste_{entry_id}"]}, + blocking=False, + ) diff --git a/__pycache__/button.cpython-313.pyc b/__pycache__/button.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d968350bfad0eb56d206c75ecad76180e3f140c8 GIT binary patch literal 2916 zcmb7GO>7&-6`uX&l1ow~rO+{@$ZPor+=!yxRz_+its~1)6v?6iuGu=aip7RpQQMMB zW_HmNu^6tN7;J>$~~kZj5J1L=NZg^vNcF&cXimm5=#mzk56?w#WG;%M3R-0H!`&qhyNMI%V-OCc?`D4gRu_Wfm6jjt#d8?IZk zm3qZ+muk4G08&WkQg<_eRS_5Acq zep;LL_+`U!Ji#P$dI`g6+C|+cm2_yZT$slbvFSFjUF;l)9!mgz3(DS2U?-3QAt^9< z+Vvu{xdNB(9ogw7!8^zL6`Q|7=A#6&h+X|a7I=+2&)PzP9YF=*4ATvUg3A0G+!CV- zD+$eXl}250mrdn;>g%k>+eXz~k$c<>evs%%b*qG9us}}bJ-%ePhR2mn*NahG8WG?1 zI1Wath-DBsOjrb%Cl+ZS=oFqvUDb5R#uZ9lu(O4F(~J8qb-;;r;#AzkuYzG86z6SV z&(JDrhf%cu?)5v@S7%ys{O-h^iM6E%)lE6Gdg*VG*IM!9{ozlCTaoy#z(vK?nOzA9 zM;?f4A8ql#mkw!v$Zvnf977^qL!mP$%PGd2+6AK!TMNNQvEG`Dd zc`ZQiu%yXakZM6KghORX4dVfr;@L4^D`P#b?B3{UdO7KRt{Qg3s3_Q6!ltvFy}BY- zOV&iENQ;fDBY9v&e9O$pznGTb^*RuXeZJnXtq&R|Sg4%GJ7#6+V8;$w2O^7) zkg<}kTejuux_9*Ob;)+SPJy9w92f*Qgi<4)ME{aX-{wE*YaJhZxcu=1R=eixv98KUq@<{p<5!gO?=3EW^NGJpp@_LY6nu|da#L+Q0^yCzDF^NUr}cbyHqiIFiNRIrZ`Ia#mrH& zIZrOuYPe+ChFilc>8XQ?V%J>AwuW7LNzSPuAE|<{Bb)%{36^VC9ZX&|FYH56qYi*) zQXupZe!y@45QDA0Z0PWuKz~5LeC3^Yrt|PNU|hohg2&0M6bVlgjX~(-$_<7rsePZp2QmNv;0D-+ug?kGJ~A9*zBeV(a@0t&z;bpKXl1 zxizj$pb~v*h&g`U4ZKtwdr?P8eD><~CR5z09 zW^!~pHo6`gZN-wHY{W*tiH-eJK!a!YB@`LByKrY=J#}d-JiQx1k+EG7#WUYIXWC^Qk3p;MbvQ4`N-2q)M)rz3J;=>y}^ZuLO^ii*a zfEK)D9_P3ikC2r~3}6Jk0v3D(03l19%*S7pTymqT8&4 z#E|dEJGKm;9QdO6pPX#5R;}0Ih^~|M-&jl#&l@^yMgB2bpzSLsm>Fj6SN}pp(p~%)61A~| literal 0 HcmV?d00001 diff --git a/__pycache__/config_flow.cpython-313.pyc b/__pycache__/config_flow.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c3a6102585f4b3b200e3fe320a52a4d331f61f0 GIT binary patch literal 2023 zcma(ROKcNIboT4N*RU=Lra^89NmhUfA_%3bkU|m)Nie~SsZ=bj7JHp+YVU^Gb<^Y& zfz+a2nhR2TsdDInp30?C4?XtKqgNbarbUWWwNh@bj9T^9H*0SKs1+S)-n=*S-kbOD zBoa{s>+JFa^E&~dUnvo+SP$v^3CJU)B8*f**g}7x`rsTyaGyD#0}SrNrI;F-U`xsW;!vaNn>gYD#~62k z>FUN(KN>`8B7@Xqi7#Y>9}mt_RFx)pd}xtXQtP8rwpBG3v(=itnyuP6TXb9lXFt&4 z$eNaGEMna?Z7a7&7339$B+I^pW>_vZ4F_(bS3tO6IE|VMRAlN#{)(2Lygsu>O@bS7 z`o{IiYx(u`%evL_l2K{Y40sP!jH(X8L~!ko42vMbnw9c_!3aU_{MDI;;}I$$%s{>% zPnc6#%&Qz0jw3aq3MH`=Duu>bDxmUbLIA~5c$`;58MMqVGdKddiZ1d>lyt4}9qq<| zda);i;QGm{x>czeSv}kJ^F8+;NT#+5^N$3!p zA{S7VzW-&9po(LR8k*pS&>iZRzlzKO?#CT|QTZY_##uc07zy|e&etW1yc(vSUgWBb z5?Sx>h2LGGC1|>)Uduu`uH*<$W38|xL`lu8kXS``bq!N8vEdE{4z}A{rRm*a2OFYq~Xq@Klo`5J`8|tg_}T*{d4#L!HE?CvQz^ z>eSVl>yt!qpy*dHb(0+Ka+a~7yN2HmYaq`%My-k^`VqBA8TK&g= zd3yw4GdtGoJJ$}O^k^IL=_G(e@TXA`_*ovn%N82I(aP*|l8g5Dp&G5qC4y~a3mlKyI<{KCjc_l`|nr7*BL(@o9)9N;z z4}ixt?OsE#^gWFS3Mj5t)c!}GHy+{r)H<_(VJIBVoW8>f7?jTD0hW!IM((_RO literal 0 HcmV?d00001 diff --git a/__pycache__/const.cpython-313.pyc b/__pycache__/const.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..83462fcd2f7af0aaaf48b60a3852263d0db1cea4 GIT binary patch literal 436 zcmXv~O-lk%6n&FAI+GtDBCcWxf{c(h?IIX-B0D%dy*0zOV-%uYOMXT@^PQJ;9rm@Pu#2bkxf0H4R0;{Xw0dFF)+FJQnQ zfyYz3sbQY6Ni=2*)y|-Ac5SSguIn({dT?2L(Cb-kw>z*=+qxaowf3t!y0fsdcSn>B zZMWOQ;?}Ti*Xddm^__B@frI6~X%EdFbF3T3qPJgUlXixMaoRy^zQn@SESf9=ehqaQ zq@Bi>lGYhTQGUq9N~s_P)kND-^(d*VDvhMLD%BEQ9#oIt%TSY_e^h3K A^Z)<= literal 0 HcmV?d00001 diff --git a/__pycache__/sensor.cpython-313.pyc b/__pycache__/sensor.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..edf248d3a02e8bacd13849d32a0aa226b9bda08d GIT binary patch literal 10862 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 literal 0 HcmV?d00001 diff --git a/button.py b/button.py new file mode 100644 index 0000000..ed387ed --- /dev/null +++ b/button.py @@ -0,0 +1,66 @@ +"""Button platform for Uster Waste.""" +import logging +from typing import Optional + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, MANUAL_REFRESH + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the button.""" + config = entry.data + name = config.get("name", "Uster Waste") + + entity = UsterWasteButton( + entry_id=entry.entry_id, + name=name + ) + async_add_entities([entity]) + + +class UsterWasteButton(ButtonEntity): + """Uster Waste Button Entity for manual refresh.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:refresh" + + def __init__( + self, + entry_id: str, + name: str + ): + self._entry_id = entry_id + self._attr_name = MANUAL_REFRESH + self._attr_unique_id = f"uster_waste_{entry_id}_refresh" + + async def async_press(self) -> None: + """Handle the button press (manual refresh).""" + # Trigger a manual update of the sensor + hass = self.hass + coordinator = None + + # Find the coordinator for this entry + if DOMAIN in hass.data: + for entry_id, entry_data in hass.data[DOMAIN].items(): + if entry_id == self._entry_id and "coordinator" in entry_data: + coordinator = entry_data["coordinator"] + break + + if coordinator: + await coordinator.async_update() + # Force sensor to update + for entity in coordinator.entities: + await entity.async_update() + entity.async_write_ha_state() + else: + _LOGGER.error("Coordinator not found for manual refresh") \ No newline at end of file diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..536c013 --- /dev/null +++ b/config_flow.py @@ -0,0 +1,48 @@ +"""Config flow for Uster Waste integration.""" +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, +}) + + +class UsterWasteConfigFlow(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.""" + 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"}, + ) + + # 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) diff --git a/const.py b/const.py new file mode 100644 index 0000000..35a4c76 --- /dev/null +++ b/const.py @@ -0,0 +1,13 @@ +"""Constants for the Uster Waste integration.""" +DOMAIN = "uster_waste" + +# Attributes for sensor +ATTR_NEXT_COLLECTION = "next_collection" +ATTR_DATE = "date" +ATTR_TYPE = "type" +ATTR_DAYS_UNTIL = "days_until" +ATTR_ENTRIES = "entries" +ATTR_ERROR = "error" + +# UI Labels +MANUAL_REFRESH = "manual_refresh" diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..64e0a29 --- /dev/null +++ b/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "uster_waste", + "name": "Uster Waste Collection", + "codeowners": [ + "@rolfinho" + ], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/rolfinho/uster_waste", + "integration_type": "service", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/rolfinho/uster_waste/issues", + "platforms": ["sensor", "button"], + "requirements": ["aiohttp", "beautifulsoup4"], + "version": "0.0.1" +} + diff --git a/sensor.py b/sensor.py new file mode 100644 index 0000000..09050a2 --- /dev/null +++ b/sensor.py @@ -0,0 +1,237 @@ +"""Sensor platform for Uster Waste.""" +import asyncio +import logging +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.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + DOMAIN, + ATTR_NEXT_COLLECTION, + ATTR_DATE, + ATTR_TYPE, + ATTR_DAYS_UNTIL, + ATTR_ENTRIES, + ATTR_ERROR, + MANUAL_REFRESH, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(days=1) + +# Swiss date helpers +MONTH_MAP = { + "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.""" + 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 + 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}'") + return None + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor.""" + config = entry.data + token = config["token"] + waste_id = config["id"] + name = config.get("name", "Uster Waste") + + session = async_get_clientsession(hass) + coordinator = UsterWasteDataUpdateCoordinator(hass, session, token, waste_id) + + entity = UsterWasteSensor( + entry_id=entry.entry_id, + coordinator=coordinator, + name=name + ) + async_add_entities([entity], update_before_add=True) + + +class UsterWasteDataUpdateCoordinator: + """Fetch data from Uster website.""" + + def __init__( + self, + hass: HomeAssistant, + session: aiohttp.ClientSession, + token: str, + waste_id: str, + ): + self.hass = hass + self.session = session + self.token = token + self.waste_id = waste_id + self.data = None + self.last_error = None + self.last_updated = None + + async def async_update(self) -> dict: + """Fetch data (cache if valid).""" + # 1. Check cache (valid for 24h) + if self.last_updated and (datetime.now() - self.last_updated) < SCAN_INTERVAL: + return self.data + + url = ( + "https://www.uster.ch/abfallstrassenabschnitt" + f"?strassenabschnitt%5B_token%5D={self.token}" + f"&strassenabschnitt%5BstrassenabschnittId%5D={self.waste_id}" + ) + + try: + async with self.session.get(url, timeout=10) as response: + if response.status == 403 or response.status == 404: + raise Exception( + "Token expired or invalid. " + "Please get a fresh URL from https://www.uster.ch/abfallstrassenabschnitt" + ) + response.raise_for_status() + html = await response.text() + + # Parse HTML + soup = BeautifulSoup(html, "html.parser") + table = soup.find("table", class_="table table-striped") + if not table: + table = soup.find("table") + if not table: + raise ValueError("No table found on page.") + + rows = table.find_all("tr") + if len(rows) < 2: + raise ValueError("Table has no data rows.") + + entries = [] + now = datetime.now() + + for row in rows[1:4]: # Next 3 entries + cols = row.find_all("td") + if len(cols) < 2: + continue + + collection_type = cols[0].get_text(strip=True) + date_str = cols[1].get_text(strip=True).replace(" \u00a0", " ") # Clean no-break space + dt = _parse_date(date_str) + if not dt: + _LOGGER.warning(f"Skipping row with invalid date: {date_str}") + continue + + entries.append({ + "Sammlung": collection_type, + "Wann?": date_str, + "date_obj": dt, + "days_until": (dt - now).days + }) + + # Sort by date (ascending) + entries.sort(key=lambda x: x["date_obj"]) + + self.data = { + "next_collection": entries[0]["Sammlung"] if entries else None, + "date": entries[0]["Wann?"] if entries else None, + "type": entries[0]["Sammlung"] if entries else None, + "days_until": entries[0]["days_until"] if entries else None, + "entries": [ + { + "type": e["Sammlung"], + "date": e["Wann?"], + "days_until": e["days_until"] + } + for e in entries[:3] + ] + } + self.last_updated = datetime.now() + + except Exception as e: + _LOGGER.error("Error fetching Uster data: %s", e) + self.data = { + ATTR_ERROR: str(e), + "next_collection": None, + "date": None, + "entries": [] + } + self.last_error = str(e) + + return self.data + + +class UsterWasteSensor(SensorEntity): + """Uster Waste Sensor Entity.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:recycle" + _attr_device_class = None + + def __init__( + self, + entry_id: str, + coordinator: UsterWasteDataUpdateCoordinator, + name: str + ): + self._entry_id = entry_id + self.coordinator = coordinator + self._attr_name = f"{name} Schedule" + self._attr_unique_id = f"uster_waste_{entry_id}" + self._attr_extra_state_attributes = { + ATTR_ENTRIES: [], + } + + async def async_update(self): + """Update sensor state.""" + self._attr_native_value = len(self.coordinator.data.get("entries", [])) + self._attr_extra_state_attributes.update( + { + ATTR_NEXT_COLLECTION: self.coordinator.data.get("next_collection"), + ATTR_DATE: self.coordinator.data.get("date"), + ATTR_TYPE: self.coordinator.data.get("type"), + ATTR_DAYS_UNTIL: self.coordinator.data.get("days_until"), + ATTR_ENTRIES: self.coordinator.data.get("entries", []), + ATTR_ERROR: self.coordinator.data.get("error") + } + ) + + async def async_added_to_hass(self): + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener( + self.async_write_ha_state, self.coordinator.last_updated + ) + ) + # Force first update + await self.async_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_updated is not None + + async def async_press(self): + """Handle the button press (manual refresh).""" + await self.async_update() + self.async_write_ha_state()