new version

This commit is contained in:
Rolf
2026-02-04 20:33:00 +01:00
parent ec8145e1a0
commit db3e4a3cce
11 changed files with 427 additions and 1 deletions

View File

@@ -1 +1 @@
# Home Assistant Plugin - Uster Waste Management Calendar
# uster_waste

45
__init__.py Normal file
View File

@@ -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,
)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

66
button.py Normal file
View File

@@ -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")

48
config_flow.py Normal file
View File

@@ -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)

13
const.py Normal file
View File

@@ -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"

17
manifest.json Normal file
View File

@@ -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"
}

237
sensor.py Normal file
View File

@@ -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()