feat: grab all collection events in 1 call and dispatch in several sensors after

It becomes a bit of a mess with a lot of dynamically named sensors, and possibly a lot of empty ones.
It adds a bit of a burden on the user to clean-up the mess. :/
This commit is contained in:
Xavier Morel
2025-04-04 18:07:03 +02:00
parent 67016339b3
commit 893043e4ce
7 changed files with 138 additions and 182 deletions

View File

@@ -27,11 +27,9 @@ PLATFORMS: list[Platform] = [Platform.SENSOR]
class EcocitoData: class EcocitoData:
"""Ecocito data type.""" """Ecocito data type."""
garbage_collections: CollectionDataUpdateCoordinator | None collection_types: dict[int, str]
garbage_collections_previous: CollectionDataUpdateCoordinator | None collections: CollectionDataUpdateCoordinator
recycling_collections: CollectionDataUpdateCoordinator | None waste_depot_visits: WasteDepotVisitsDataUpdateCoordinator
recycling_collections_previous: CollectionDataUpdateCoordinator | None
waste_depot_visits: WasteDepotVisitsDataUpdateCoordinator # Maybe we could have an optional here if we had some checkbox config?
type EcocitoConfigEntry = ConfigEntry[EcocitoData] type EcocitoConfigEntry = ConfigEntry[EcocitoData]
@@ -46,31 +44,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: EcocitoConfigEntry) -> b
) )
await client.authenticate() await client.authenticate()
garbage_id = entry.data.get(ECOCITO_GARBAGE_TYPE)
recycle_id = entry.data.get(ECOCITO_RECYCLE_TYPE)
refresh_time = entry.data.get(ECOCITO_REFRESH_MIN_KEY, ECOCITO_DEFAULT_REFRESH_MIN) refresh_time = entry.data.get(ECOCITO_REFRESH_MIN_KEY, ECOCITO_DEFAULT_REFRESH_MIN)
collect_types = await client.get_collection_types()
data = EcocitoData( data = EcocitoData(
garbage_collections=CollectionDataUpdateCoordinator( collection_types = collect_types,
hass, client, 0, garbage_id, refresh_time collections = CollectionDataUpdateCoordinator(
) if garbage_id is not None else None, hass, client, refresh_time
garbage_collections_previous=CollectionDataUpdateCoordinator( ),
hass, client, -1, garbage_id, refresh_time
) if garbage_id is not None else None,
recycling_collections=CollectionDataUpdateCoordinator(
hass, client, 0, recycle_id, refresh_time
) if recycle_id is not None else None,
recycling_collections_previous=CollectionDataUpdateCoordinator(
hass, client, -1, recycle_id, refresh_time
) if recycle_id is not None else None,
waste_depot_visits=WasteDepotVisitsDataUpdateCoordinator( waste_depot_visits=WasteDepotVisitsDataUpdateCoordinator(
hass, client, 0, refresh_time hass, client, 0, refresh_time
), ),
) )
for field in fields(data): await data.collections.async_config_entry_first_refresh()
coordinator = getattr(data, field.name) await data.waste_depot_visits.async_config_entry_first_refresh()
if coordinator:
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = data entry.runtime_data = data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -106,8 +106,8 @@ class EcocitoClient:
) from e ) from e
async def get_collection_events( async def get_collection_events(
self, event_type: str, year: int self, year: int
) -> list[CollectionEvent]: ) -> dict[str, dict[str, CollectionEvent]]:
"""Return the list of the collection events for a type and a year.""" """Return the list of the collection events for a type and a year."""
async with aiohttp.ClientSession(cookie_jar=self._cookies) as session: async with aiohttp.ClientSession(cookie_jar=self._cookies) as session:
try: try:
@@ -119,8 +119,8 @@ class EcocitoClient:
"skip": "0", "skip": "0",
"take": "1000", "take": "1000",
"requireTotalCount": "true", "requireTotalCount": "true",
"idMatiere": str(event_type), "idMatiere": str(-1),
"dateDebut": f"{year}-01-01T00:00:00.000Z", "dateDebut": f"{year - 1}-01-01T00:00:00.000Z",
"dateFin": f"{year}-12-31T23:59:59.999Z", "dateFin": f"{year}-12-31T23:59:59.999Z",
}, },
raise_for_status=True, raise_for_status=True,
@@ -128,15 +128,24 @@ class EcocitoClient:
content = await response.text() content = await response.text()
try: try:
return [ result = {}
CollectionEvent( for row in json.loads(content).get("data", []):
type=event_type, date = datetime.fromisoformat(row["DATE_DONNEE"])
date=datetime.fromisoformat(row["DATE_DONNEE"]), y = "current" if date.year == year else "last"
location=row["LIBELLE_ADRESSE"], matter = row["ID_MATIERE"]
quantity=row["QUANTITE_NETTE"], if y not in result:
result[y] = {}
if matter not in result[y]:
result[y][matter] = []
result[y][matter].append(
CollectionEvent(
type=matter,
date=date,
location=row["LIBELLE_ADRESSE"],
quantity=row["QUANTITE_NETTE"],
)
) )
for row in json.loads(content).get("data", []) return result
]
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
await self._handle_error(content, e) await self._handle_error(content, e)

View File

@@ -64,21 +64,18 @@ class CollectionDataUpdateCoordinator(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
client: EcocitoClient, client: EcocitoClient,
year_offset: int,
coll_type_id: int,
refresh_time: int refresh_time: int
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
self.cached: dict[str, dict[str, list[CollectionEvent]]] | None = None
super().__init__(hass, client, refresh_time) super().__init__(hass, client, refresh_time)
self._year_offset = year_offset
self._coll_type_id = coll_type_id
async def _fetch_data(self) -> list[CollectionEvent]: async def _fetch_data(self) -> list[CollectionEvent]:
"""Fetch the data.""" """Fetch the data."""
return await self.client.get_collection_events( if self.cached is None:
str(self._coll_type_id), self.cached = await self.client.get_collection_events(datetime.now(tz=self._time_zone).year)
datetime.now(tz=self._time_zone).year + self._year_offset,
) return self.cached
class WasteDepotVisitsDataUpdateCoordinator( class WasteDepotVisitsDataUpdateCoordinator(

View File

@@ -9,5 +9,8 @@
"issue_tracker": "https://github.com/rclsilver/home-assistant-ecocito/issues", "issue_tracker": "https://github.com/rclsilver/home-assistant-ecocito/issues",
"loggers": ["custom_components.ecocito"], "loggers": ["custom_components.ecocito"],
"single_config_entry": true, "single_config_entry": true,
"version": "0.0.0" "version": "0.0.0",
"requirements": [
"unidecode>=1.3.8"
]
} }

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import unidecode
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime from datetime import datetime
from typing import Any, Generic from typing import Any, Generic
@@ -18,21 +19,25 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EcocitoConfigEntry from . import EcocitoConfigEntry
from .client import CollectionEvent, EcocitoEvent from .client import CollectionEvent, EcocitoEvent
from .const import DEVICE_ATTRIBUTION from .const import DEVICE_ATTRIBUTION, LOGGER
from .coordinator import T from .coordinator import T
from .entity import EcocitoEntity from .entity import EcocitoEntity
def get_count(data: list[Any]) -> int: def get_count(data: list[Any]) -> int:
"""Return the size of the given list.""" """Return the size of the given list."""
return len(data) if data:
return len(data)
else:
return 0
def get_event_collections_weight(data: list[CollectionEvent]) -> int: def get_event_collections_weight(data: list[CollectionEvent]) -> int:
"""Return the sum of the events quantities.""" """Return the sum of the events quantities."""
result = 0 result = 0
for row in data: if data:
result += row.quantity for row in data:
result += row.quantity
return result return result
@@ -59,152 +64,91 @@ class EcocitoSensorEntityDescription(SensorEntityDescription, Generic[T]):
def __init__( def __init__(
self, self,
year: str | None,
mat_type: str | None,
value_fn: Callable[[T], str | int], value_fn: Callable[[T], str | int],
last_updated_fn: Callable[[T], datetime], last_updated_fn: Callable[[T], datetime],
*args: tuple, *args: tuple,
**kwargs: dict[str, any], **kwargs: dict[str, any],
) -> None: ) -> None:
"""Build a Ecocito sensor.""" """Build a Ecocito sensor."""
self.mat_type = mat_type
self.year = year
self.value_fn = value_fn self.value_fn = value_fn
self.last_updated_fn = last_updated_fn self.last_updated_fn = last_updated_fn
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def cleanup_name(matter_name) -> str:
matter_name = str(matter_name).replace(" ", "_")
return unidecode.unidecode(matter_name, "utf-8").lower()
SENSOR_TYPES: tuple[tuple[str, EcocitoSensorEntityDescription]] = ( def build_sensor_types(mat_types) -> list[tuple[str, EcocitoSensorEntityDescription]]:
( sensors = []
"garbage_collections", for mat_type, mat_type_name in mat_types.items():
EcocitoSensorEntityDescription( if int(mat_type) == -1:
key="garbage_collections_count", continue
translation_key="garbage_collections_count", name = cleanup_name(str(mat_type_name))
value_fn=get_count, for year in ["current", "last"]:
last_updated_fn=get_latest_date, sensors.append((
icon="mdi:trash-can", "collections",
state_class=SensorStateClass.TOTAL, EcocitoSensorEntityDescription(
), name=f"{name}_collections_count_{year}_year",
), key=f"{name}_collections_count_{year}_year",
( # translation_key=f"garbage_collections_count_{year}_year",
"garbage_collections", year=year,
EcocitoSensorEntityDescription( mat_type=mat_type,
key="garbage_collections_total", value_fn=get_count,
translation_key="garbage_collections_total", last_updated_fn=get_latest_date,
icon="mdi:trash-can", icon="mdi:trash-can",
value_fn=get_event_collections_weight, state_class=SensorStateClass.TOTAL,
last_updated_fn=get_latest_date, ),
unit_of_measurement=UnitOfMass.KILOGRAMS, ))
state_class=SensorStateClass.TOTAL, sensors.append((
suggested_display_precision=0, "collections",
), EcocitoSensorEntityDescription(
), name=f"{name}_collections_total_{year}_year",
( key=f"{name}_collections_total_{year}_year",
"garbage_collections", # translation_key=f"garbage_collections_total_{year}_year",
EcocitoSensorEntityDescription( icon="mdi:trash-can",
key="latest_garbage_collections", year=year,
translation_key="latest_garbage_collection", mat_type=mat_type,
icon="mdi:trash-can", value_fn=get_event_collections_weight,
value_fn=get_latest_event_collection_weight, last_updated_fn=get_latest_date,
last_updated_fn=get_latest_date, unit_of_measurement=UnitOfMass.KILOGRAMS,
unit_of_measurement=UnitOfMass.KILOGRAMS, state_class=SensorStateClass.TOTAL,
state_class=SensorStateClass.TOTAL, suggested_display_precision=0,
suggested_display_precision=0, ),
), ))
), sensors.append((
( "collections",
"garbage_collections_previous", EcocitoSensorEntityDescription(
EcocitoSensorEntityDescription( key=f"latest_{name}_collection",
key="garbage_collections_count_previous", name=f"latest_{name}_collection",
translation_key="previous_garbage_collections_count", # translation_key=f"latest_garbage_collection",
icon="mdi:trash-can", icon="mdi:trash-can",
value_fn=get_count, year=year,
last_updated_fn=get_latest_date, mat_type=mat_type,
state_class=SensorStateClass.TOTAL, value_fn=get_latest_event_collection_weight,
), last_updated_fn=get_latest_date,
), unit_of_measurement=UnitOfMass.KILOGRAMS,
( state_class=SensorStateClass.TOTAL,
"garbage_collections_previous", suggested_display_precision=0,
EcocitoSensorEntityDescription( ),
key="garbage_collections_total_previous", ))
translation_key="previous_garbage_collections_total", sensors.append((
icon="mdi:trash-can",
value_fn=get_event_collections_weight,
last_updated_fn=get_latest_date,
unit_of_measurement=UnitOfMass.KILOGRAMS,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=0,
),
),
(
"recycling_collections",
EcocitoSensorEntityDescription(
key="recycling_collections_count",
translation_key="recycling_collections_count",
icon="mdi:recycle",
value_fn=get_count,
last_updated_fn=get_latest_date,
state_class=SensorStateClass.TOTAL,
),
),
(
"recycling_collections",
EcocitoSensorEntityDescription(
key="recycling_collections_total",
translation_key="recycling_collections_total",
icon="mdi:recycle",
value_fn=get_event_collections_weight,
last_updated_fn=get_latest_date,
unit_of_measurement=UnitOfMass.KILOGRAMS,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=0,
),
),
(
"recycling_collections",
EcocitoSensorEntityDescription(
key="latest_recycling_collections",
translation_key="latest_recycling_collection",
icon="mdi:recycle",
value_fn=get_latest_event_collection_weight,
last_updated_fn=get_latest_date,
unit_of_measurement=UnitOfMass.KILOGRAMS,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=0,
),
),
(
"recycling_collections_previous",
EcocitoSensorEntityDescription(
key="recycling_collections_count_previous",
translation_key="previous_recycling_collections_count",
icon="mdi:recycle",
value_fn=get_count,
last_updated_fn=get_latest_date,
state_class=SensorStateClass.TOTAL,
),
),
(
"recycling_collections_previous",
EcocitoSensorEntityDescription(
key="recycling_collections_total_previous",
translation_key="previous_recycling_collections_total",
icon="mdi:recycle",
value_fn=get_event_collections_weight,
last_updated_fn=get_latest_date,
unit_of_measurement=UnitOfMass.KILOGRAMS,
state_class=SensorStateClass.TOTAL,
suggested_display_precision=0,
),
),
(
"waste_depot_visits", "waste_depot_visits",
EcocitoSensorEntityDescription( EcocitoSensorEntityDescription(
key="waste_deposit_visit", key="waste_deposit_visit",
translation_key="waste_deposit_visit", translation_key="waste_deposit_visit",
icon="mdi:car", icon="mdi:car",
year=None,
mat_type=None,
value_fn=get_count, value_fn=get_count,
last_updated_fn=get_latest_date, last_updated_fn=get_latest_date,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
), ),
), ))
) return sensors
class EcocitoSensor(EcocitoEntity[T], SensorEntity): class EcocitoSensor(EcocitoEntity[T], SensorEntity):
@@ -216,14 +160,25 @@ class EcocitoSensor(EcocitoEntity[T], SensorEntity):
@property @property
def native_value(self) -> str | int: def native_value(self) -> str | int:
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data) data = self.coordinator.data
if self.entity_description.year is not None:
data = data.get(self.entity_description.year)
if self.entity_description.mat_type is not None:
data = data.get(self.entity_description.mat_type)
return self.entity_description.value_fn(data)
@property @property
def extra_state_attributes(self) -> dict[str, str] | None: def extra_state_attributes(self) -> dict[str, str] | None:
"""Return the state attributes of the sensor.""" """Return the state attributes of the sensor."""
data = self.coordinator.data
if self.entity_description.year is not None:
data = data.get(self.entity_description.year)
if self.entity_description.mat_type is not None:
data = data.get(self.entity_description.mat_type)
return { return {
"last_updated": self.entity_description.last_updated_fn( "last_updated": self.entity_description.last_updated_fn(
self.coordinator.data data
), ),
} }
@@ -234,8 +189,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Ecocito sensors based on a config entry.""" """Set up Ecocito sensors based on a config entry."""
entities: list[EcocitoSensor[Any]] = [] entities: list[EcocitoSensor[Any]] = []
for coordinator_type, description in SENSOR_TYPES: mat_types = entry.runtime_data.collection_types
for coordinator_type, description in build_sensor_types(mat_types):
coordinator = getattr(entry.runtime_data, coordinator_type) coordinator = getattr(entry.runtime_data, coordinator_type)
if coordinator: if coordinator:
entities.append(EcocitoSensor(coordinator, description)) entities.append(EcocitoSensor(coordinator, description))

View File

@@ -20,19 +20,19 @@
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"garbage_collections_count": { "garbage_collections_count_current_year": {
"name": "Number of garbage collections" "name": "Number of garbage collections"
}, },
"garbage_collections_total": { "garbage_collections_total_current_year": {
"name": "Total weight of collected garbage" "name": "Total weight of collected garbage"
}, },
"latest_garbage_collection": { "latest_garbage_collection_current_year": {
"name": "Weight of the latest garbage collection" "name": "Weight of the latest garbage collection"
}, },
"previous_garbage_collections_count": { "garbage_collections_count_last_year": {
"name": "Number of garbage collections (last year)" "name": "Number of garbage collections (last year)"
}, },
"previous_garbage_collections_total": { "garbage_collections_total_last_year": {
"name": "Total weight of collected garbage (last year)" "name": "Total weight of collected garbage (last year)"
}, },
"recycling_collections_count": { "recycling_collections_count": {

View File

@@ -3,3 +3,4 @@ colorlog==6.8.2
homeassistant==2025.3.4 homeassistant==2025.3.4
pip>=21.3.1 pip>=21.3.1
ruff==0.11.0 ruff==0.11.0
unidecode>=1.3.8