mirror of
https://github.com/mx42/home-assistant-ecocito.git
synced 2026-03-15 04:05:49 +01:00
Initial version
This commit is contained in:
61
.devcontainer.json
Normal file
61
.devcontainer.json
Normal 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
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
56
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
56
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal 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
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
48
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
48
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
15
.github/dependabot.yml
vendored
Normal 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
32
.github/workflows/lint.yml
vendored
Normal 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
54
.github/workflows/release.yml
vendored
Normal 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
37
.github/workflows/validate.yml
vendored
Normal 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
7
.gitignore
vendored
@@ -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
34
.ruff.toml
Normal 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
61
CONTRIBUTING.md
Normal 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
63
Dockerfile.dev
Normal 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
36
README.md
Normal 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
|
||||
|
||||
[](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
|
||||
9
config/configuration.yaml
Normal file
9
config/configuration.yaml
Normal 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
|
||||
67
custom_components/ecocito/__init__.py
Normal file
67
custom_components/ecocito/__init__.py
Normal 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)
|
||||
170
custom_components/ecocito/client.py
Normal file
170
custom_components/ecocito/client.py
Normal 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
|
||||
61
custom_components/ecocito/config_flow.py
Normal file
61
custom_components/ecocito/config_flow.py
Normal 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
|
||||
)
|
||||
37
custom_components/ecocito/const.py
Normal file
37
custom_components/ecocito/const.py
Normal 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"
|
||||
113
custom_components/ecocito/coordinator.py
Normal file
113
custom_components/ecocito/coordinator.py
Normal 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
|
||||
)
|
||||
36
custom_components/ecocito/entity.py
Normal file
36
custom_components/ecocito/entity.py
Normal 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)},
|
||||
)
|
||||
15
custom_components/ecocito/errors.py
Normal file
15
custom_components/ecocito/errors.py
Normal 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."""
|
||||
13
custom_components/ecocito/manifest.json
Normal file
13
custom_components/ecocito/manifest.json
Normal 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"
|
||||
}
|
||||
241
custom_components/ecocito/sensor.py
Normal file
241
custom_components/ecocito/sensor.py
Normal 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)
|
||||
58
custom_components/ecocito/translations/en.json
Normal file
58
custom_components/ecocito/translations/en.json
Normal 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
7
hacs.json
Normal 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
5
requirements.txt
Normal 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
20
script/develop
Executable 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
8
script/lint
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
ruff format .
|
||||
ruff check . --fix
|
||||
7
script/setup
Executable file
7
script/setup
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
python3 -m pip install --requirement requirements.txt
|
||||
Reference in New Issue
Block a user