From df600eb18e0ae7b9001b50fb489d6748b9c8e8bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20B=C3=A9trancourt?= Date: Fri, 18 Oct 2024 12:10:41 +0000 Subject: [PATCH] Initial version --- .devcontainer.json | 61 +++++ .gitattributes | 1 + .github/ISSUE_TEMPLATE/bug.yml | 56 ++++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 48 ++++ .github/dependabot.yml | 15 ++ .github/workflows/lint.yml | 32 +++ .github/workflows/release.yml | 54 ++++ .github/workflows/validate.yml | 37 +++ .gitignore | 7 + .ruff.toml | 34 +++ CONTRIBUTING.md | 61 +++++ Dockerfile.dev | 63 +++++ README.md | 36 +++ config/configuration.yaml | 9 + custom_components/ecocito/__init__.py | 67 +++++ custom_components/ecocito/client.py | 170 ++++++++++++ custom_components/ecocito/config_flow.py | 61 +++++ custom_components/ecocito/const.py | 37 +++ custom_components/ecocito/coordinator.py | 113 ++++++++ custom_components/ecocito/entity.py | 36 +++ custom_components/ecocito/errors.py | 15 ++ custom_components/ecocito/manifest.json | 13 + custom_components/ecocito/sensor.py | 241 ++++++++++++++++++ .../ecocito/translations/en.json | 58 +++++ hacs.json | 7 + requirements.txt | 5 + script/develop | 20 ++ script/lint | 8 + script/setup | 7 + 30 files changed, 1373 insertions(+) create mode 100644 .devcontainer.json create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/validate.yml create mode 100644 .ruff.toml create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile.dev create mode 100644 README.md create mode 100644 config/configuration.yaml create mode 100644 custom_components/ecocito/__init__.py create mode 100644 custom_components/ecocito/client.py create mode 100644 custom_components/ecocito/config_flow.py create mode 100644 custom_components/ecocito/const.py create mode 100644 custom_components/ecocito/coordinator.py create mode 100644 custom_components/ecocito/entity.py create mode 100644 custom_components/ecocito/errors.py create mode 100644 custom_components/ecocito/manifest.json create mode 100644 custom_components/ecocito/sensor.py create mode 100644 custom_components/ecocito/translations/en.json create mode 100644 hacs.json create mode 100644 requirements.txt create mode 100755 script/develop create mode 100755 script/lint create mode 100755 script/setup diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..a51fffe --- /dev/null +++ b/.devcontainer.json @@ -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": {} +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..667686d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -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)" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..fa83460 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..eee9634 --- /dev/null +++ b/.github/dependabot.yml @@ -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" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..e67f621 --- /dev/null +++ b/.github/workflows/lint.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..df6c699 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..fcfaabb --- /dev/null +++ b/.github/workflows/validate.yml @@ -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" diff --git a/.gitignore b/.gitignore index 82f9275..0edba76 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..c37521f --- /dev/null +++ b/.ruff.toml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..61cb7c5 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..5c8ad26 --- /dev/null +++ b/Dockerfile.dev @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f179ff --- /dev/null +++ b/README.md @@ -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 diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..b07ec19 --- /dev/null +++ b/config/configuration.yaml @@ -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 diff --git a/custom_components/ecocito/__init__.py b/custom_components/ecocito/__init__.py new file mode 100644 index 0000000..fae677f --- /dev/null +++ b/custom_components/ecocito/__init__.py @@ -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) diff --git a/custom_components/ecocito/client.py b/custom_components/ecocito/client.py new file mode 100644 index 0000000..987d662 --- /dev/null +++ b/custom_components/ecocito/client.py @@ -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 diff --git a/custom_components/ecocito/config_flow.py b/custom_components/ecocito/config_flow.py new file mode 100644 index 0000000..990495d --- /dev/null +++ b/custom_components/ecocito/config_flow.py @@ -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 + ) diff --git a/custom_components/ecocito/const.py b/custom_components/ecocito/const.py new file mode 100644 index 0000000..b71a7c1 --- /dev/null +++ b/custom_components/ecocito/const.py @@ -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" diff --git a/custom_components/ecocito/coordinator.py b/custom_components/ecocito/coordinator.py new file mode 100644 index 0000000..9a23e0f --- /dev/null +++ b/custom_components/ecocito/coordinator.py @@ -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 + ) diff --git a/custom_components/ecocito/entity.py b/custom_components/ecocito/entity.py new file mode 100644 index 0000000..cbddd35 --- /dev/null +++ b/custom_components/ecocito/entity.py @@ -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)}, + ) diff --git a/custom_components/ecocito/errors.py b/custom_components/ecocito/errors.py new file mode 100644 index 0000000..ac7975f --- /dev/null +++ b/custom_components/ecocito/errors.py @@ -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.""" diff --git a/custom_components/ecocito/manifest.json b/custom_components/ecocito/manifest.json new file mode 100644 index 0000000..f3dd117 --- /dev/null +++ b/custom_components/ecocito/manifest.json @@ -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" +} diff --git a/custom_components/ecocito/sensor.py b/custom_components/ecocito/sensor.py new file mode 100644 index 0000000..a04b209 --- /dev/null +++ b/custom_components/ecocito/sensor.py @@ -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) diff --git a/custom_components/ecocito/translations/en.json b/custom_components/ecocito/translations/en.json new file mode 100644 index 0000000..9d09cff --- /dev/null +++ b/custom_components/ecocito/translations/en.json @@ -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" + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..94c5ebf --- /dev/null +++ b/hacs.json @@ -0,0 +1,7 @@ +{ + "name": "ecocito", + "hide_default_branch": true, + "homeassistant": "2024.6.0", + "country": ["FR"], + "render_readme": true +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d81a333 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +beautifulsoup4 +colorlog==6.8.2 +homeassistant==2024.6.0 +pip>=21.3.1 +ruff==0.6.7 diff --git a/script/develop b/script/develop new file mode 100755 index 0000000..89eda50 --- /dev/null +++ b/script/develop @@ -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 /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..5d68d15 --- /dev/null +++ b/script/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff format . +ruff check . --fix diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..141d19f --- /dev/null +++ b/script/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt