Initial version

This commit is contained in:
Thomas Bétrancourt
2024-10-18 12:10:41 +00:00
parent 67d0375f99
commit df600eb18e
30 changed files with 1373 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
"""The ecocito integration."""
from __future__ import annotations
from dataclasses import dataclass, fields
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from .client import EcocitoClient
from .coordinator import (
GarbageCollectionsDataUpdateCoordinator,
RecyclingCollectionsDataUpdateCoordinator,
WasteDepotVisitsDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.SENSOR]
@dataclass(kw_only=True, slots=True)
class EcocitoData:
"""Ecocito data type."""
garbage_collections: GarbageCollectionsDataUpdateCoordinator
garbage_collections_previous: GarbageCollectionsDataUpdateCoordinator
recycling_collections: RecyclingCollectionsDataUpdateCoordinator
recycling_collections_previous: RecyclingCollectionsDataUpdateCoordinator
waste_depot_visits: WasteDepotVisitsDataUpdateCoordinator
type EcocitoConfigEntry = ConfigEntry[EcocitoData]
async def async_setup_entry(hass: HomeAssistant, entry: EcocitoConfigEntry) -> bool:
"""Set up ecocito from a config entry."""
client = EcocitoClient(
entry.data[CONF_DOMAIN],
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
)
await client.authenticate()
data = EcocitoData(
garbage_collections=GarbageCollectionsDataUpdateCoordinator(hass, client, 0),
garbage_collections_previous=GarbageCollectionsDataUpdateCoordinator(
hass, client, -1
),
recycling_collections=RecyclingCollectionsDataUpdateCoordinator(
hass, client, 0
),
recycling_collections_previous=RecyclingCollectionsDataUpdateCoordinator(
hass, client, -1
),
waste_depot_visits=WasteDepotVisitsDataUpdateCoordinator(hass, client, 0),
)
for field in fields(data):
coordinator = getattr(data, field.name)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: EcocitoConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,170 @@
"""Client."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import datetime
import aiohttp
from bs4 import BeautifulSoup as bs # noqa: N813
from .const import (
ECOCITO_COLLECTION_ENDPOINT,
ECOCITO_DEFAULT_COLLECTION_TYPE,
ECOCITO_GARBAGE_COLLECTION_TYPE,
ECOCITO_LOGIN_ENDPOINT,
ECOCITO_LOGIN_PASSWORD_KEY,
ECOCITO_LOGIN_URI,
ECOCITO_LOGIN_USERNAME_KEY,
ECOCITO_RECYCLING_COLLECTION_TYPE,
ECOCITO_WASTE_DEPOSIT_ENDPOINT,
LOGGER,
)
from .errors import EcocitoError, InvalidAuthenticationError
@dataclass(kw_only=True, slots=True)
class EcocitoEvent:
"""Represent a Ecocito event."""
date: datetime
@dataclass(kw_only=True, slots=True)
class CollectionEvent(EcocitoEvent):
"""Represents a garbage or recycling collection event."""
date: datetime
location: str
type: str
quantity: float
@dataclass(kw_only=True, slots=True)
class WasteDepotVisit(EcocitoEvent):
"""Represents a voluntary waste depot visit."""
class EcocitoClient:
"""Ecocito client."""
def __init__(self, domain: str, username: str, password: str) -> None:
"""Init the Ecocito client."""
self._domain = domain.split(".")[0]
self._username = username
self._password = password
self._cookies = aiohttp.CookieJar()
async def authenticate(self) -> None:
"""Authenticate to Ecocito."""
async with aiohttp.ClientSession(cookie_jar=self._cookies) as session:
try:
async with session.post(
ECOCITO_LOGIN_ENDPOINT.format(self._domain),
data={
ECOCITO_LOGIN_USERNAME_KEY: self._username,
ECOCITO_LOGIN_PASSWORD_KEY: self._password,
},
raise_for_status=True,
) as response:
if not self._cookies:
raise InvalidAuthenticationError
html = bs(await response.text(), "html.parser")
error = html.find_all("div", {"class": "validation-summary-errors"})
if error:
raise InvalidAuthenticationError(error[0].find("li").text)
LOGGER.debug("Connected as %s", self._username)
except aiohttp.ClientError as e:
raise EcocitoError(f"Authentication error: {e}") from e
async def get_collection_events(
self, event_type: str, year: int
) -> list[CollectionEvent]:
"""Return the list of the collection events for a type and a year."""
async with aiohttp.ClientSession(cookie_jar=self._cookies) as session:
try:
while True:
async with session.get(
ECOCITO_COLLECTION_ENDPOINT.format(self._domain),
params={
"charger": "true",
"skip": "0",
"take": "1000",
"requireTotalCount": "true",
"idMatiere": str(event_type),
"dateDebut": f"{year}-01-01T00:00:00.000Z",
"dateFin": f"{year}-12-31T23:59:59.999Z",
},
raise_for_status=True,
) as response:
content = await response.text()
try:
return [
CollectionEvent(
type=event_type,
date=datetime.fromisoformat(row["DATE_DONNEE"]),
location=row["LIBELLE_ADRESSE"],
quantity=row["QUANTITE_NETTE"],
)
for row in json.loads(content).get("data", [])
]
except Exception as e: # noqa: BLE001
await self._handle_error(content, e)
except aiohttp.ClientError as e:
raise EcocitoError(f"Unable to get collection events: {e}") from e
async def get_garbage_collections(self, year: int) -> list[CollectionEvent]:
"""Return the list of the garbage collections for a year."""
return await self.get_collection_events(ECOCITO_GARBAGE_COLLECTION_TYPE, year)
async def get_recycling_collections(self, year: int) -> list[CollectionEvent]:
"""Return the list of the recycling collections for a year."""
return await self.get_collection_events(ECOCITO_RECYCLING_COLLECTION_TYPE, year)
async def get_waste_depot_visits(self, year: int) -> list[WasteDepotVisit]:
"""Return the list of the waste depot visits for a year."""
async with aiohttp.ClientSession(cookie_jar=self._cookies) as session:
try:
while True:
async with session.get(
ECOCITO_WASTE_DEPOSIT_ENDPOINT.format(self._domain),
params={
"charger": "true",
"skip": "0",
"take": "1000",
"requireTotalCount": "true",
"idMatiere": str(ECOCITO_DEFAULT_COLLECTION_TYPE),
"dateDebut": f"{year}-01-01T00:00:00.000Z",
"dateFin": f"{year}-12-31T23:59:59.999Z",
},
raise_for_status=True,
) as response:
content = await response.text()
try:
return [
WasteDepotVisit(
date=datetime.fromisoformat(row["DATE_DONNEE"])
)
for row in json.loads(content).get("data", [])
]
except Exception as e: # noqa: BLE001
await self._handle_error(content, e)
except aiohttp.ClientError as e:
raise EcocitoError(f"Unable to get waste deposit visits: {e}") from e
async def _handle_error(self, content: str, e: Exception):
"""Handle a request error by checking for login form and re-authenticating if necessary."""
html = bs(content, "html.parser")
form = html.find("form", action=re.compile(f"{ECOCITO_LOGIN_URI}"))
if form:
LOGGER.debug("The session has expired, try to login again.")
await self.authenticate()
else:
raise EcocitoError("Unhandled request error") from e

View File

@@ -0,0 +1,61 @@
"""Config flow for ecocito integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DOMAIN, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .client import EcocitoClient
from .const import DOMAIN
from .errors import CannotConnectError, InvalidAuthenticationError
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_DOMAIN): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect."""
client = EcocitoClient(data[CONF_DOMAIN], data[CONF_USERNAME], data[CONF_PASSWORD])
await client.authenticate()
class EcocitoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for ecocito."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
await validate_input(self.hass, user_input)
except CannotConnectError:
errors["base"] = "cannot_connect"
except InvalidAuthenticationError:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -0,0 +1,37 @@
"""Constants for the ecocito integration."""
import logging
DOMAIN = "ecocito"
LOGGER = logging.getLogger(__package__)
# Config Flow
# Service Device
DEVICE_ATTRIBUTION = "Données fournies par Ecocito"
DEVICE_NAME = "Ecocito"
DEVICE_MANUFACTURER = "Ecocito"
DEVICE_MODEL = "Calendrier Ecocito"
# Ecocito - Base
ECOCITO_DOMAIN = "{}.ecocito.com"
# Ecocito - Login
ECOCITO_LOGIN_URI = "/Usager/Profil/Connexion"
ECOCITO_LOGIN_ENDPOINT = f"https://{ECOCITO_DOMAIN}{ECOCITO_LOGIN_URI}"
ECOCITO_LOGIN_USERNAME_KEY = "Identifiant"
ECOCITO_LOGIN_PASSWORD_KEY = "MotDePasse" # noqa: S105
# Ecocito - Collection types
ECOCITO_DEFAULT_COLLECTION_TYPE = -1
ECOCITO_GARBAGE_COLLECTION_TYPE = 15
ECOCITO_RECYCLING_COLLECTION_TYPE = 16
# Ecocito - Collection endpoint
ECOCITO_COLLECTION_ENDPOINT = f"https://{ECOCITO_DOMAIN}/Usager/Collecte/GetCollecte"
# Ecocito - Waste deposit visits
ECOCITO_WASTE_DEPOSIT_ENDPOINT = f"https://{ECOCITO_DOMAIN}/Usager/Apport/GetApport"

View File

@@ -0,0 +1,113 @@
"""Data update coordinator for the Lidarr integration."""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from typing import Generic, TypeVar
from zoneinfo import ZoneInfo
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 .client import CollectionEvent, EcocitoClient, WasteDepotVisit
from .const import DOMAIN, LOGGER
from .errors import CannotConnectError, InvalidAuthenticationError
T = TypeVar("T", bound=list[CollectionEvent] | list[WasteDepotVisit])
class EcocitoDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC):
"""Data update coordinator for the Ecocito integration."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: EcocitoClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass=hass,
logger=LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
)
self.client = client
self._time_zone = ZoneInfo(hass.config.time_zone)
async def _async_update_data(self) -> T:
"""Get the latest data from Ecocito."""
try:
return await self._fetch_data()
except CannotConnectError as ex:
raise UpdateFailed(ex) from ex
except InvalidAuthenticationError as ex:
raise ConfigEntryAuthFailed(
"Credentials are no longer valid. Please reauthenticate"
) from ex
@abstractmethod
async def _fetch_data(self) -> T:
"""Fetch the actual data."""
raise NotImplementedError
class GarbageCollectionsDataUpdateCoordinator(
EcocitoDataUpdateCoordinator[list[CollectionEvent]]
):
"""Garbage collections list update from Ecocito."""
def __init__(
self, hass: HomeAssistant, client: EcocitoClient, year_offset: int
) -> None:
"""Initialize the coordinator."""
super().__init__(hass, client)
self._year_offset = year_offset
async def _fetch_data(self) -> list[CollectionEvent]:
"""Fetch the data."""
return await self.client.get_garbage_collections(
datetime.now(tz=self._time_zone).year + self._year_offset
)
class RecyclingCollectionsDataUpdateCoordinator(
EcocitoDataUpdateCoordinator[list[CollectionEvent]]
):
"""Recycling collections list update from Ecocito."""
def __init__(
self, hass: HomeAssistant, client: EcocitoClient, year_offset: int
) -> None:
"""Initialize the coordinator."""
super().__init__(hass, client)
self._year_offset = year_offset
async def _fetch_data(self) -> list[CollectionEvent]:
"""Fetch the data."""
return await self.client.get_recycling_collections(
datetime.now(tz=self._time_zone).year + self._year_offset
)
class WasteDepotVisitsDataUpdateCoordinator(
EcocitoDataUpdateCoordinator[list[WasteDepotVisit]]
):
"""Waste depot visits list update from Ecocito."""
def __init__(
self, hass: HomeAssistant, client: EcocitoClient, year_offset: int
) -> None:
"""Initialize the coordinator."""
super().__init__(hass, client)
self._year_offset = year_offset
async def _fetch_data(self) -> list[CollectionEvent]:
"""Fetch the data."""
return await self.client.get_waste_depot_visits(
datetime.now(tz=self._time_zone).year + self._year_offset
)

View File

@@ -0,0 +1,36 @@
"""The Lidarr component."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEVICE_MANUFACTURER, DEVICE_MODEL, DEVICE_NAME, DOMAIN
from .coordinator import EcocitoDataUpdateCoordinator, T
class EcocitoEntity[T](CoordinatorEntity[EcocitoDataUpdateCoordinator[T]]):
"""Defines a base Ecocito entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: EcocitoDataUpdateCoordinator[T],
description: EntityDescription,
) -> None:
"""Initialize the Ecocito entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.config_entry.entry_id}_{description.key}".lower()
)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=DEVICE_NAME,
manufacturer=DEVICE_MANUFACTURER,
model=DEVICE_MODEL,
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
)

View File

@@ -0,0 +1,15 @@
"""Errors for the Hue component."""
from homeassistant.exceptions import HomeAssistantError
class EcocitoError(HomeAssistantError):
"""Base class for ecocito exceptions."""
class CannotConnectError(EcocitoError):
"""Unable to connect to the ecocito servers."""
class InvalidAuthenticationError(EcocitoError):
"""Error to indicate there is invalid auth."""

View File

@@ -0,0 +1,13 @@
{
"domain": "ecocito",
"name": "ecocito",
"codeowners": ["@rclsilver"],
"config_flow": true,
"documentation": "https://github.com/rclsilver/home-assistant-ecocito",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/rclsilver/home-assistant-ecocito/issues",
"loggers": ["custom_components.ecocito"],
"single_config_entry": true,
"version": "0.0.0"
}

View File

@@ -0,0 +1,241 @@
"""Support for Lidarr."""
from __future__ import annotations
import dataclasses
from collections.abc import Callable
from datetime import datetime
from typing import Any, Generic
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfMass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EcocitoConfigEntry
from .client import CollectionEvent, EcocitoEvent
from .const import DEVICE_ATTRIBUTION
from .coordinator import T
from .entity import EcocitoEntity
def get_count(data: list[Any]) -> int:
"""Return the size of the given list."""
return len(data)
def get_event_collections_weight(data: list[CollectionEvent]) -> int:
"""Return the sum of the events quantities."""
result = 0
for row in data:
result += row.quantity
return result
def get_latest_date(data: list[EcocitoEvent]) -> datetime | None:
"""Return the date of the latest collection event."""
if not data:
return None
return max(data, key=lambda event: event.date).date
def get_latest_event_collection_weight(data: list[CollectionEvent]) -> int:
"""Return the weight of the latest event."""
if not data:
return 0
latest_event = max(data, key=lambda event: event.date)
latest_date = latest_event.date.date()
return sum(event.quantity for event in data if event.date.date() == latest_date)
@dataclasses.dataclass
class EcocitoSensorEntityDescription(SensorEntityDescription, Generic[T]):
"""Class to describe a Ecocito sensor."""
def __init__(
self,
value_fn: Callable[[T], str | int],
last_updated_fn: Callable[[T], datetime],
*args: tuple,
**kwargs: dict[str, any],
) -> None:
"""Build a Ecocito sensor."""
self.value_fn = value_fn
self.last_updated_fn = last_updated_fn
super().__init__(*args, **kwargs)
SENSOR_TYPES: tuple[tuple[str, EcocitoSensorEntityDescription]] = (
(
"garbage_collections",
EcocitoSensorEntityDescription(
key="garbage_collections_count",
translation_key="garbage_collections_count",
value_fn=get_count,
last_updated_fn=get_latest_date,
icon="mdi:trash-can",
state_class=SensorStateClass.TOTAL,
),
),
(
"garbage_collections",
EcocitoSensorEntityDescription(
key="garbage_collections_total",
translation_key="garbage_collections_total",
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,
),
),
(
"garbage_collections",
EcocitoSensorEntityDescription(
key="latest_garbage_collections",
translation_key="latest_garbage_collection",
icon="mdi:trash-can",
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,
),
),
(
"garbage_collections_previous",
EcocitoSensorEntityDescription(
key="garbage_collections_count_previous",
translation_key="previous_garbage_collections_count",
icon="mdi:trash-can",
value_fn=get_count,
last_updated_fn=get_latest_date,
state_class=SensorStateClass.TOTAL,
),
),
(
"garbage_collections_previous",
EcocitoSensorEntityDescription(
key="garbage_collections_total_previous",
translation_key="previous_garbage_collections_total",
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",
EcocitoSensorEntityDescription(
key="waste_deposit_visit",
translation_key="waste_deposit_visit",
icon="mdi:car",
value_fn=get_count,
last_updated_fn=get_latest_date,
state_class=SensorStateClass.TOTAL,
),
),
)
class EcocitoSensor(EcocitoEntity[T], SensorEntity):
"""Implementation of the Ecocito sensor."""
_attr_attribution = DEVICE_ATTRIBUTION
_attr_has_entity_name = True
@property
def native_value(self) -> str | int:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)
@property
def extra_state_attributes(self) -> dict[str, str] | None:
"""Return the state attributes of the sensor."""
return {
"last_updated": self.entity_description.last_updated_fn(
self.coordinator.data
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: EcocitoConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Ecocito sensors based on a config entry."""
entities: list[EcocitoSensor[Any]] = []
for coordinator_type, description in SENSOR_TYPES:
coordinator = getattr(entry.runtime_data, coordinator_type)
entities.append(EcocitoSensor(coordinator, description))
async_add_entities(entities)

View File

@@ -0,0 +1,58 @@
{
"config": {
"step": {
"user": {
"data": {
"domain": "Ecocito domain",
"username": "Ecocito username",
"password": "Ecocito password"
}
}
},
"error": {
"cannot_connect": "Error while connecting to Ecocito",
"invalid_auth": "Authentication error",
"unknown": "Unknown error"
},
"abort": {
"already_configured": "Already configured"
}
},
"entity": {
"sensor": {
"garbage_collections_count": {
"name": "Number of garbage collections"
},
"garbage_collections_total": {
"name": "Total weight of collected garbage"
},
"latest_garbage_collection": {
"name": "Weight of the latest garbage collection"
},
"previous_garbage_collections_count": {
"name": "Number of garbage collections (last year)"
},
"previous_garbage_collections_total": {
"name": "Total weight of collected garbage (last year)"
},
"recycling_collections_count": {
"name": "Number of recycling collections"
},
"recycling_collections_total": {
"name": "Total weight of collected recycling"
},
"latest_recycling_collection": {
"name": "Weight of the latest recycling collection"
},
"previous_recycling_collections_count": {
"name": "Number of recycling collections (last year)"
},
"previous_recycling_collections_total": {
"name": "Total weight of collected recycling (last year)"
},
"waste_deposit_visit": {
"name": "Number of visits to waste deposit"
}
}
}
}