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

61
.devcontainer.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "rclsilver/home-assistant-ecocito",
"dockerFile": "Dockerfile.dev",
"postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && script/setup",
"containerEnv": {
"PYTHONASYNCIODEBUG": "1"
},
"forwardPorts": [8123],
"portsAttributes": {
"8123": {
"label": "Home Assistant",
"onAutoForward": "notify"
}
},
"customizations": {
"vscode": {
"extensions": [
"charliermarsh.ruff",
"github.vscode-pull-request-github",
"ms-python.python",
"ms-python.pylint",
"ms-python.vscode-pylance",
"ryanluker.vscode-coverage-gutters",
"visualstudioexptteam.vscodeintellicode",
"redhat.vscode-yaml",
"esbenp.prettier-vscode"
],
"settings": {
"python.experiments.optOutFrom": ["pythonTestAdapter"],
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
"python.pythonPath": "/home/vscode/.local/ha-venv/bin/python",
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"files.trimTrailingWhitespace": true,
"terminal.integrated.profiles.linux": {
"zsh": {
"path": "/usr/bin/zsh"
}
},
"terminal.integrated.defaultProfile.linux": "zsh",
"yaml.customTags": [
"!input scalar",
"!secret scalar",
"!include_dir_named scalar",
"!include_dir_list scalar",
"!include_dir_merge_list scalar",
"!include_dir_merge_named scalar"
],
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
}
}
}
},
"remoteUser": "vscode",
"features": {}
}

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

56
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
---
name: "Bug report"
description: "Report a bug with the integration"
labels:
- "Bug"
body:
- type: markdown
attributes:
value: Before you open a new issue, search through the existing issues to see if others have had the same problem.
- type: textarea
attributes:
label: "System Health details"
description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)"
validations:
required: true
- type: checkboxes
attributes:
label: Checklist
options:
- label: I have enabled debug logging for my installation.
required: true
- label: I have filled out the issue template to the best of my ability.
required: true
- label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue).
required: true
- label: This issue is not a duplicate issue of any [previous issues](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Bug%22+)..
required: true
- type: textarea
attributes:
label: "Describe the issue"
description: "A clear and concise description of what the issue is."
validations:
required: true
- type: textarea
attributes:
label: Reproduction steps
description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed."
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
attributes:
label: "Debug logs"
description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue."
render: text
validations:
required: true
- type: textarea
attributes:
label: "Diagnostics dump"
description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)"

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,48 @@
---
name: "Feature request"
description: "Suggest an idea for this project"
labels:
- "Feature+Request"
body:
- type: markdown
attributes:
value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea.
- type: checkboxes
attributes:
label: Checklist
options:
- label: I have filled out the template to the best of my ability.
required: true
- label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request).
required: true
- label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Feature+Request%22+).
required: true
- type: textarea
attributes:
label: "Is your feature request related to a problem? Please describe."
description: "A clear and concise description of what the problem is."
placeholder: "I'm always frustrated when [...]"
validations:
required: true
- type: textarea
attributes:
label: "Describe the solution you'd like"
description: "A clear and concise description of what you want to happen."
validations:
required: true
- type: textarea
attributes:
label: "Describe alternatives you've considered"
description: "A clear and concise description of any alternative solutions or features you've considered."
validations:
required: true
- type: textarea
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here."
validations:
required: true

15
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
ignore:
# Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json
- dependency-name: "homeassistant"

32
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: "Lint"
on:
push:
branches:
- "**"
pull_request:
branches:
- master"
jobs:
ruff:
name: "Ruff"
runs-on: "ubuntu-latest"
steps:
- name: "Checkout the repository"
uses: "actions/checkout@v4.1.7"
- name: "Set up Python"
uses: actions/setup-python@v5.2.0
with:
python-version: "3.12"
cache: "pip"
- name: "Install requirements"
run: python3 -m pip install -r requirements.txt
- name: "Lint"
run: python3 -m ruff check .
- name: "Format"
run: python3 -m ruff format . --check

54
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: "Release"
on:
push:
branches:
- master
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
jobs:
release:
name: "Release"
runs-on: "ubuntu-latest"
permissions:
contents: write
id-token: write
steps:
- name: "Checkout the repository"
uses: "actions/checkout@v4.1.7"
with:
fetch-depth: ${{ !startsWith(github.ref, 'refs/tags/v') && 20 || 1 }}
fetch-tags: ${{ !startsWith(github.ref, 'refs/tags/v') }}
- name: Get current version
id: version
run: |
version=$(git describe --tag --match 'v*.*.*' 2>/dev/null || true)
if [ -z "${version}" ]; then
commit_count=$(git rev-list --all --count)
version="0.0.0-${commit_count}-$(git rev-parse --short HEAD)"
fi
echo "version=$version" >> ${GITHUB_ENV}
- name: "ZIP the integration directory"
shell: "bash"
run: |
set -x
cd "${{ github.workspace }}/custom_components/ecocito"
jq --arg version ${version} '.version = $version' manifest.json > manifest.tmp.json && mv manifest.tmp.json manifest.json
zip ecocito-${version}.zip -r ./
- name: Publish the release
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
automatic_release_tag: ${{ !startsWith(github.ref, 'refs/tags/v') && 'latest' || null }}
prerelease: ${{ !startsWith(github.ref, 'refs/tags/v') }}
title: ${{ startsWith(github.ref, 'refs/tags/v') && env.version || 'Development Build' }}
files: |
${{ github.workspace }}/custom_components/ecocito/ecocito-${{ env.version }}.zip

37
.github/workflows/validate.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: "Validate"
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
push:
branches:
- "master"
pull_request:
branches:
- "master"
jobs:
hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest
name: "Hassfest Validation"
runs-on: "ubuntu-latest"
steps:
- name: "Checkout the repository"
uses: "actions/checkout@v4.1.7"
- name: "Run hassfest validation"
uses: "home-assistant/actions/hassfest@master"
hacs: # https://github.com/hacs/action
name: "HACS Validation"
runs-on: "ubuntu-latest"
steps:
- name: "Checkout the repository"
uses: "actions/checkout@v4.1.7"
- name: "Run HACS validation"
uses: "hacs/action@main"
with:
category: "integration"
# Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands
ignore: "brands"

7
.gitignore vendored
View File

@@ -160,3 +160,10 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# vscode
.vscode/
# Home Assistant configuration
config/*
!config/configuration.yaml

34
.ruff.toml Normal file
View File

@@ -0,0 +1,34 @@
# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
target-version = "py312"
[lint]
select = [
"ALL",
]
ignore = [
"ANN101", # Missing type annotation for `self` in method
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed
"ARG001", # ARG001, we don't use i
"D203", # no-blank-line-before-class (incompatible with formatter)
"D212", # multi-line-summary-first-line (incompatible with formatter)
"COM812", # incompatible with formatter
"ISC001", # incompatible with formatter
# Moving imports into type-checking blocks can mess with pytest.patch()
"TCH001", # Move application import {} into a type-checking block
"TCH002", # Move third-party import {} into a type-checking block
"TCH003", # Move standard library import {} into a type-checking block
"TRY003", # Avoid specifying long messages outside the exception class
]
[lint.flake8-pytest-style]
fixture-parentheses = false
[lint.pyupgrade]
keep-runtime-typing = true
[lint.mccabe]
max-complexity = 25

61
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,61 @@
# Contribution guidelines
Contributing to this project should be as easy and transparent as possible, whether it's:
- Reporting a bug
- Discussing the current state of the code
- Submitting a fix
- Proposing new features
## Github is used for everything
Github is used to host code, to track issues and feature requests, as well as accept pull requests.
Pull requests are the best way to propose changes to the codebase.
1. Fork the repo and create your branch from `main`.
2. If you've changed something, update the documentation.
3. Make sure your code lints (using `scripts/lint`).
4. Test you contribution.
5. Issue that pull request!
## Any contributions you make will be under the MIT Software License
In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using Github's [issues](../../issues)
GitHub issues are used to track public bugs.
Report a bug by [opening a new issue](../../issues/new/choose); it's that easy!
## Write bug reports with detail, background, and sample code
**Great Bug Reports** tend to have:
- A quick summary and/or background
- Steps to reproduce
- Be specific!
- Give sample code if you can.
- What you expected would happen
- What actually happens
- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
People _love_ thorough bug reports. I'm not even kidding.
## Use a Consistent Coding Style
Use [black](https://github.com/ambv/black) to make sure the code follows the style.
## Test your code modification
This custom component is based on [integration_blueprint template](https://github.com/ludeeus/integration_blueprint).
It comes with development environment in a container, easy to launch
if you use Visual Studio Code. With this container you will have a stand alone
Home Assistant instance running and already configured with the included
[`configuration.yaml`](./config/configuration.yaml)
file.
## License
By contributing, you agree that your contributions will be licensed under its MIT License.

63
Dockerfile.dev Normal file
View File

@@ -0,0 +1,63 @@
FROM mcr.microsoft.com/devcontainers/python:1-3.12
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Uninstall pre-installed formatting and linting tools
# They would conflict with our pinned versions
RUN \
pipx uninstall pydocstyle \
&& pipx uninstall pycodestyle \
&& pipx uninstall mypy \
&& pipx uninstall pylint
RUN \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
# Additional library needed by some tests and accordingly by VScode Tests Discovery
bluez \
ffmpeg \
libudev-dev \
libavformat-dev \
libavcodec-dev \
libavdevice-dev \
libavutil-dev \
libgammu-dev \
libswscale-dev \
libswresample-dev \
libavfilter-dev \
libpcap-dev \
libturbojpeg0 \
libyaml-dev \
libxml2 \
git \
cmake \
vim \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install uv
RUN pip3 install uv
WORKDIR /usr/src
# Setup hass-release
RUN git clone --depth 1 https://github.com/home-assistant/hass-release \
&& uv pip install --system -e hass-release/ \
&& chown -R vscode /usr/src/hass-release/data
USER vscode
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
RUN uv venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /tmp
# Install Python dependencies from requirements
COPY requirements.txt ./
RUN uv pip install -r requirements.txt
WORKDIR /workspaces
# Set the default shell to bash instead of sh
ENV SHELL /bin/bash

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# Integration Blueprint
[![GitHub Release][releases-shield]][releases]
[![GitHub Activity][commits-shield]][commits]
[![License][license-shield]](LICENSE)
Integration to connect with the French waste collection service [Ecocito](https://www.ecocito.com/).
## Install with HACS
[![Install with HACS.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=rclsilver&repository=home-assistant-ecocito&category=integration)
More information about HACS [here](https://hacs.xyz/).
## Manual installation
1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`).
1. If you do not have a `custom_components` directory (folder) there, you need to create it.
1. In the `custom_components` directory (folder) create a new folder called `ecocito`.
1. Download _all_ the files from the `custom_components/ecocito/` directory (folder) in this repository.
1. Place the files you downloaded in the new directory (folder) you created.
1. Restart Home Assistant
1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Ecocito"
## Contributions are welcome!
If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md)
---
[commits-shield]: https://img.shields.io/github/commit-activity/y/rclsilver/home-assistant-ecocito.svg?style=for-the-badge
[commits]: https://github.com/rclsilver/home-assistant-ecocito/commits/master
[exampleimg]: example.png
[license-shield]: https://img.shields.io/github/license/rclsilver/home-assistant-ecocito.svg?style=for-the-badge
[releases-shield]: https://img.shields.io/github/release/rclsilver/home-assistant-ecocito.svg?style=for-the-badge
[releases]: https://github.com/rclsilver/home-assistant-ecocito/releases

View File

@@ -0,0 +1,9 @@
# https://www.home-assistant.io/integrations/homeassistant/
homeassistant:
debug: true
# https://www.home-assistant.io/integrations/logger/
logger:
default: info
logs:
custom_components.ecocito: debug

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

7
hacs.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "ecocito",
"hide_default_branch": true,
"homeassistant": "2024.6.0",
"country": ["FR"],
"render_readme": true
}

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
beautifulsoup4
colorlog==6.8.2
homeassistant==2024.6.0
pip>=21.3.1
ruff==0.6.7

20
script/develop Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
# Create config dir if not present
if [[ ! -d "${PWD}/config" ]]; then
mkdir -p "${PWD}/config"
hass --config "${PWD}/config" --script ensure_config
fi
# Set the path to custom_components
## This let's us have the structure we want <root>/custom_components/integration_blueprint
## while at the same time have Home Assistant configuration inside <root>/config
## without resulting to symlinks.
export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"
# Start Home Assistant
hass --config "${PWD}/config" --debug

8
script/lint Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
ruff format .
ruff check . --fix

7
script/setup Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")/.."
python3 -m pip install --requirement requirements.txt