├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug.yml ├── pr-labeler.yml ├── workflows │ ├── pr-labeler.yml │ ├── release-drafter.yaml │ ├── auto-assign.yml │ ├── issues-labeler.yml │ ├── release.yml │ ├── validate.yml │ └── CI.yml ├── dependabot.yml └── release-drafter.yml ├── requirements.txt ├── hacs.json ├── images ├── capteurs.png ├── controles.png ├── config_carte.png ├── consommation.png ├── historique.png ├── veolialogo.png └── dashboard_eau.png ├── .gitignore ├── custom_components └── veolia │ ├── manifest.json │ ├── const.py │ ├── data.py │ ├── entity.py │ ├── __init__.py │ ├── coordinator.py │ ├── translations │ ├── en.json │ └── fr.json │ ├── config_flow.py │ ├── binary_sensor.py │ ├── text.py │ ├── model.py │ ├── switch.py │ └── sensor.py ├── renovate.json ├── LICENSE ├── .pre-commit-config.yaml ├── README.md └── pyproject.toml /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @Jezza34000 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | veolia_api==2.1.0 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Veolia Eau", 3 | "country": "FR", 4 | "homeassistant": "2025.1.0" 5 | } 6 | -------------------------------------------------------------------------------- /images/capteurs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jezza34000/homeassistant_veolia/HEAD/images/capteurs.png -------------------------------------------------------------------------------- /images/controles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jezza34000/homeassistant_veolia/HEAD/images/controles.png -------------------------------------------------------------------------------- /images/config_carte.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jezza34000/homeassistant_veolia/HEAD/images/config_carte.png -------------------------------------------------------------------------------- /images/consommation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jezza34000/homeassistant_veolia/HEAD/images/consommation.png -------------------------------------------------------------------------------- /images/historique.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jezza34000/homeassistant_veolia/HEAD/images/historique.png -------------------------------------------------------------------------------- /images/veolialogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jezza34000/homeassistant_veolia/HEAD/images/veolialogo.png -------------------------------------------------------------------------------- /images/dashboard_eau.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jezza34000/homeassistant_veolia/HEAD/images/dashboard_eau.png -------------------------------------------------------------------------------- /.github/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | feature: ["feature/*", "feat/*"] 2 | enhancement: enhancement/* 3 | bug: fix/* 4 | breaking: breaking/* 5 | documentation: doc/* 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .pytest* 3 | *.egg-info 4 | .coverage 5 | .vscode 6 | coverage.xml 7 | .ruff_cache 8 | /get_annual_example.txt 9 | /get_monthly_exemple.txt 10 | -------------------------------------------------------------------------------- /.github/workflows/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | name: PR Labeler 2 | on: 3 | pull_request: 4 | types: [opened] 5 | 6 | jobs: 7 | pr-labeler: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: TimonVS/pr-labeler-action@f9c084306ce8b3f488a8f3ee1ccedc6da131d1af # Version 5.0.0 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yaml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Update release draft 13 | uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # Version 6.1.0 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | ignore: 14 | - dependency-name: "homeassistant" 15 | -------------------------------------------------------------------------------- /custom_components/veolia/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "veolia", 3 | "name": "Veolia Eau", 4 | "codeowners": ["@Jezza34000"], 5 | "config_flow": true, 6 | "dependencies": ["recorder"], 7 | "documentation": "https://github.com/Jezza34000/homeassistant_veolia", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/Jezza34000/homeassistant_veolia/issues", 10 | "loggers": ["veolia"], 11 | "requirements": ["veolia_api==2.1.0"], 12 | "version": "2.0.1" 13 | } 14 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "digest": { 4 | "enabled": false 5 | }, 6 | "extends": ["config:recommended", ":disableDependencyDashboard"], 7 | "ignoreUnstable": true, 8 | "packageRules": [ 9 | { 10 | "automerge": false, 11 | "description": "GitHub Actions - Version updates only for stable releases", 12 | "matchDepTypes": ["action"] 13 | } 14 | ], 15 | "respectLatest": true, 16 | "separateMajorMinor": false 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/auto-assign.yml: -------------------------------------------------------------------------------- 1 | name: Assign issues to Jezza34000 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - reopened 8 | 9 | permissions: 10 | issues: write 11 | 12 | jobs: 13 | assign: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Assign issue to Jezza34000 17 | uses: actions-ecosystem/action-add-assignees@a5b84af721c4a621eb9c7a4a95ec20a90d0b88e9 # Version 1.0.1 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | assignees: Jezza34000 21 | -------------------------------------------------------------------------------- /custom_components/veolia/const.py: -------------------------------------------------------------------------------- 1 | """Constants for veolia.""" 2 | 3 | from logging import Logger, getLogger 4 | 5 | LOGGER: Logger = getLogger(__package__) 6 | 7 | DOMAIN = "veolia" 8 | NAME = "Veolia" 9 | 10 | # Platforms 11 | SENSOR = "sensor" 12 | PLATFORMS = [SENSOR] 13 | 14 | # API constants keys 15 | LAST_DATA = -1 16 | IDX = "index" 17 | LITRE = "litre" 18 | CUBIC_METER = "m3" 19 | CONSO = "consommation" 20 | IDX_FIABILITY = "fiabilite_index" 21 | CONSO_FIABILITY = "fiabilite_conso" 22 | DATA_DATE = "date_releve" 23 | YEAR = "annee" 24 | MONTH = "mois" 25 | -------------------------------------------------------------------------------- /custom_components/veolia/data.py: -------------------------------------------------------------------------------- 1 | """Custom types for Veolia.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.config_entries import ConfigEntry 9 | 10 | if TYPE_CHECKING: 11 | from veolia_api import VeoliaAPI 12 | 13 | from homeassistant.loader import Integration 14 | 15 | from .coordinator import VeoliaDataUpdateCoordinator 16 | 17 | type VeoliaConfigEntry = ConfigEntry[VeoliaData] 18 | 19 | 20 | @dataclass 21 | class VeoliaData: 22 | """Data for the Veolia integration.""" 23 | 24 | client: VeoliaAPI 25 | coordinator: VeoliaDataUpdateCoordinator 26 | integration: Integration 27 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$NEXT_PATCH_VERSION" 2 | tag-template: "v$NEXT_PATCH_VERSION" 3 | categories: 4 | - title: "🚀 Features" 5 | labels: 6 | - "feature" 7 | - title: "🐛 Bug Fixes" 8 | labels: 9 | - "bug" 10 | - title: "📝 Documentation" 11 | labels: 12 | - "documentation" 13 | - title: "🔧 Maintenance" 14 | labels: 15 | - "maintenance" 16 | - title: "💡 Enhancements" 17 | labels: 18 | - "enhancement" 19 | - title: "⚠️ Breaking Changes" 20 | labels: 21 | - "breaking-change" 22 | - title: "📦 Dependencies" 23 | labels: 24 | - "dependencies" 25 | change-template: "- $TITLE @$AUTHOR (#$NUMBER)" 26 | template: | 27 | ## What's Changed 28 | $CHANGES 29 | -------------------------------------------------------------------------------- /.github/workflows/issues-labeler.yml: -------------------------------------------------------------------------------- 1 | name: Label issues based on template 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | permissions: 8 | issues: write 9 | 10 | jobs: 11 | add-label: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Add Bug label 15 | if: contains(github.event.issue.body, 'Rapport de bug') 16 | run: | 17 | gh issue edit ${{ github.event.issue.number }} --add-label "Bug" 18 | env: 19 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | - name: Add Feature label 22 | if: contains(github.event.issue.body, 'Demande de nouvelle fonctionnalité') 23 | run: | 24 | gh issue edit ${{ github.event.issue.number }} --add-label "Feature Request" 25 | env: 26 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # Version 6.0.1 19 | 20 | - name: "ZIP the integration directory" 21 | shell: "bash" 22 | run: | 23 | cd "${{ github.workspace }}/custom_components/veolia" 24 | zip veolia.zip -r ./ 25 | 26 | - name: "Upload the ZIP file to the release" 27 | uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # Version 2.5.0 28 | with: 29 | files: ${{ github.workspace }}/custom_components/integration_blueprint/veolia.zip 30 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: "Validate" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: 9 | - "main" 10 | pull_request: 11 | branches: 12 | - "main" 13 | 14 | jobs: 15 | hassfest: 16 | name: "Hassfest" 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Checkout the repository" 20 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # Version 6.0.1 21 | 22 | - name: "Run hassfest validation" 23 | uses: home-assistant/actions/hassfest@87c064c607f3c5cc673a24258d0c98d23033bfc3 # Version 11 September 2025 24 | 25 | hacs: 26 | name: "HACS" 27 | runs-on: "ubuntu-latest" 28 | steps: 29 | - name: "Checkout the repository" 30 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # Version 6.0.1 31 | 32 | - name: "Run HACS validation" 33 | uses: hacs/action@d556e736723344f83838d08488c983a15381059a # Version 22.5.0 34 | with: 35 | category: "integration" 36 | -------------------------------------------------------------------------------- /custom_components/veolia/entity.py: -------------------------------------------------------------------------------- 1 | """VeoliaEntity class.""" 2 | 3 | from homeassistant.components.sensor import SensorDeviceClass, SensorEntity 4 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 5 | 6 | from .const import DOMAIN, NAME 7 | 8 | 9 | class VeoliaMesurements(CoordinatorEntity, SensorEntity): 10 | """Representation of a Veolia entity.""" 11 | 12 | def __init__(self, coordinator, config_entry) -> None: 13 | """Initialize the entity.""" 14 | super().__init__(coordinator) 15 | self.config_entry = config_entry 16 | 17 | @property 18 | def device_info(self) -> dict: 19 | """Return device registry information for this entity.""" 20 | return { 21 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 22 | "manufacturer": NAME, 23 | "name": f"{NAME} {self.coordinator.data.id_abonnement}", 24 | } 25 | 26 | @property 27 | def device_class(self) -> str: 28 | """Return the device_class of the sensor.""" 29 | return SensorDeviceClass.WATER 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 - 2024 @Jezza34000 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Demande de nouvelle fonctionnalité" 3 | description: "Suggérer une idée pour ce projet" 4 | labels: "Feature+Request" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Avant de soumettre une nouvelle demande de fonctionnalité, recherchez parmi les demandes existantes pour voir si d'autres ont eu la même idée. 9 | 10 | - type: textarea 11 | attributes: 12 | label: "Votre demande de fonctionnalité est-elle liée à un problème ? Veuillez décrire." 13 | description: "Une description claire et concise du problème." 14 | placeholder: "Je suis toujours frustré lorsque [...]" 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | attributes: 20 | label: "Décrivez la solution que vous souhaitez" 21 | description: "Une description claire et concise de ce que vous voulez qu'il se passe." 22 | validations: 23 | required: true 24 | 25 | - type: textarea 26 | attributes: 27 | label: "Contexte supplémentaire" 28 | description: "Ajoutez tout autre contexte ou capture d'écran concernant la demande de fonctionnalité ici." 29 | validations: 30 | required: true 31 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | permissions: 12 | actions: write 13 | contents: write 14 | 15 | jobs: 16 | tests: 17 | name: "Python ${{ matrix.python-version }}" 18 | runs-on: "ubuntu-latest" 19 | 20 | strategy: 21 | matrix: 22 | python-version: ["3.13"] 23 | 24 | steps: 25 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # Version 6.0.1 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Cache pip 30 | uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # Version 5.0.1 31 | with: 32 | path: ~/.cache/pip 33 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 34 | restore-keys: | 35 | ${{ runner.os }}-pip- 36 | 37 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # Version 6.1.0 38 | with: 39 | python-version: "${{ matrix.python-version }}" 40 | 41 | - name: "Install tox" 42 | run: | 43 | python -m pip install --upgrade tox tox-gh-actions 44 | 45 | - name: "Run pre-commit with tox" 46 | run: python -m tox -e precommit 47 | -------------------------------------------------------------------------------- /custom_components/veolia/__init__.py: -------------------------------------------------------------------------------- 1 | """The Veolia integration.""" 2 | 3 | from homeassistant.config_entries import ConfigEntry 4 | from homeassistant.const import Platform 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.helpers import device_registry as dr 7 | import homeassistant.helpers.config_validation as cv 8 | from homeassistant.helpers.typing import ConfigType 9 | 10 | from .const import DOMAIN 11 | from .coordinator import VeoliaDataUpdateCoordinator 12 | from .data import VeoliaConfigEntry, VeoliaData 13 | from .sensor import LastIndexSensor 14 | 15 | __all__ = ["VeoliaData", "LastIndexSensor"] 16 | 17 | PLATFORMS: list[Platform] = [ 18 | Platform.SENSOR, 19 | Platform.SWITCH, 20 | ] 21 | 22 | CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) 23 | 24 | 25 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 26 | """Set up the Veolia integration.""" 27 | return True 28 | 29 | 30 | async def async_setup_entry(hass, entry): 31 | """Set up Veolia from a config entry.""" 32 | coordinator = VeoliaDataUpdateCoordinator(hass) 33 | await coordinator.async_config_entry_first_refresh() 34 | 35 | hass.data.setdefault(DOMAIN, {}) 36 | hass.data[DOMAIN][entry.entry_id] = coordinator 37 | 38 | await hass.config_entries.async_forward_entry_setups( 39 | entry, ["sensor", "switch", "text", "binary_sensor"] 40 | ) 41 | return True 42 | 43 | 44 | async def async_unload_entry( 45 | hass: HomeAssistant, 46 | entry: VeoliaConfigEntry, 47 | ) -> bool: 48 | """Unload a config entry.""" 49 | unload_ok = await hass.config_entries.async_unload_platforms( 50 | entry, ["sensor", "switch", "text", "binary_sensor"] 51 | ) 52 | if unload_ok: 53 | hass.data[DOMAIN].pop(entry.entry_id) 54 | return unload_ok 55 | 56 | 57 | async def async_reload_entry( 58 | hass: HomeAssistant, 59 | entry: VeoliaConfigEntry, 60 | ) -> None: 61 | """Reload config entry.""" 62 | await async_unload_entry(hass, entry) 63 | await async_setup_entry(hass, entry) 64 | 65 | 66 | async def async_remove_config_entry_device( 67 | hass: HomeAssistant, 68 | config_entry: ConfigEntry, 69 | device_entry: dr.DeviceEntry, 70 | ) -> bool: 71 | """Remove a config entry from a device.""" 72 | return True 73 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://gitlab.com/vojko.pribudic.foss/pre-commit-update 3 | rev: v0.9.0 4 | hooks: 5 | - id: pre-commit-update 6 | stages: [manual] 7 | 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v6.0.0 10 | hooks: 11 | - id: check-added-large-files 12 | - id: check-ast 13 | - id: check-toml 14 | - id: check-yaml 15 | - id: debug-statements 16 | - id: check-case-conflict 17 | - id: end-of-file-fixer 18 | - id: detect-private-key 19 | - id: mixed-line-ending 20 | - id: requirements-txt-fixer 21 | 22 | - repo: https://github.com/codespell-project/codespell 23 | rev: v2.4.1 24 | hooks: 25 | - id: codespell 26 | args: 27 | - --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn,informations 28 | - --skip="./.*,*.csv,*.json,*.ambr" 29 | - --quiet-level=2 30 | exclude_types: [csv, json, html, markdown] 31 | exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/|.github 32 | 33 | - repo: https://github.com/psf/black-pre-commit-mirror 34 | rev: 25.12.0 35 | hooks: 36 | - id: black 37 | 38 | - repo: https://github.com/astral-sh/ruff-pre-commit 39 | rev: v0.14.9 40 | hooks: 41 | - id: ruff 42 | args: 43 | - --fix 44 | - id: ruff-format 45 | 46 | - repo: https://github.com/gitleaks/gitleaks 47 | rev: v8.30.0 48 | hooks: 49 | - id: gitleaks 50 | 51 | - repo: https://github.com/pre-commit/mirrors-prettier 52 | rev: v4.0.0-alpha.8 53 | hooks: 54 | - id: prettier 55 | - repo: https://github.com/cdce8p/python-typing-update 56 | rev: v0.8.1 57 | hooks: 58 | # Run `python-typing-update` hook manually from time to time 59 | # to update python typing syntax. 60 | # Will require manual work, before submitting changes! 61 | # pre-commit run --hook-stage manual python-typing-update --all-files 62 | - id: python-typing-update 63 | stages: [manual] 64 | args: 65 | - --py311-plus 66 | - --force 67 | - --keep-updates 68 | 69 | - repo: https://github.com/renovatebot/pre-commit-hooks 70 | rev: 42.57.1 71 | hooks: 72 | - id: renovate-config-validator 73 | 74 | ci: 75 | skip: [renovate-config-validator] 76 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "Rapport de bug" 3 | description: "Signaler un bug avec l'intégration. Il est important de fournir autant d'informations que possible pour aider à résoudre le problème." 4 | labels: "Bug" 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Avant d'ouvrir un nouveau bug, recherchez parmi les bugs existants pour voir si d'autres ont déjà rencontré ce problème. 9 | - type: textarea 10 | attributes: 11 | label: "Détails de la santé du système" 12 | description: "Collez les données de la Santé du système de Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" 13 | validations: 14 | required: true 15 | - type: checkboxes 16 | attributes: 17 | label: Checklist 18 | options: 19 | - label: Cette requête ne contient qu'un seul problème (si vous constater plusieurs problèmes, ouvrez un bug pour chaque problème). 20 | required: true 21 | - label: Ce problème n'est pas un doublon de [problèmes précédents](https://github.com/Jezza34000/homeassistant_veolia/issues?q=is%3Aissue+label%3A%22Bug%22+). 22 | required: true 23 | - type: input 24 | attributes: 25 | label: "URL du site web de Veolia" 26 | description: "Copiez-collez l'URL du site web de Veolia avec laquelle vous vous connectez à votre compte client, celle qui apparait dans la barre d'adresse de votre navigateur une fois sur la page d'accueil de votre compte client." 27 | validations: 28 | required: true 29 | - type: textarea 30 | attributes: 31 | label: "Décrire le problème" 32 | description: "Une description claire et concise de ce que vous constatez." 33 | validations: 34 | required: true 35 | - type: textarea 36 | attributes: 37 | label: "Étapes de reproduction" 38 | description: "Expliquez comment reproduire le problème que vous rencontrez." 39 | value: | 40 | 1. 41 | 2. 42 | 3. 43 | ... 44 | validations: 45 | required: true 46 | - type: textarea 47 | attributes: 48 | label: "Journaux de débogage" 49 | description: "Pour activer les journaux de débogage, consultez ceci https://www.home-assistant.io/integrations/logger/, cela **doit** inclure _tout_ depuis le démarrage de Home Assistant jusqu'au point où vous rencontrez le problème. ATTENTION : Ne pas inclure de données personnelles." 50 | render: text 51 | validations: 52 | required: true 53 | -------------------------------------------------------------------------------- /custom_components/veolia/coordinator.py: -------------------------------------------------------------------------------- 1 | """DataUpdateCoordinator for integration_blueprint.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import date, datetime, timedelta 6 | from typing import TYPE_CHECKING 7 | 8 | from veolia_api import VeoliaAPI 9 | from veolia_api.exceptions import VeoliaAPIError 10 | 11 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 12 | from homeassistant.exceptions import ConfigEntryAuthFailed 13 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 14 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 15 | from homeassistant.util import dt as dt_util 16 | 17 | from .const import DOMAIN, LOGGER 18 | from .data import VeoliaConfigEntry 19 | from .model import VeoliaModel 20 | 21 | if TYPE_CHECKING: 22 | from homeassistant.core import HomeAssistant 23 | 24 | 25 | class VeoliaDataUpdateCoordinator(DataUpdateCoordinator): 26 | """Class to manage fetching data from the API.""" 27 | 28 | config_entry: VeoliaConfigEntry 29 | 30 | def __init__( 31 | self, 32 | hass: HomeAssistant, 33 | ) -> None: 34 | """Initialize.""" 35 | super().__init__( 36 | hass=hass, 37 | logger=LOGGER, 38 | name=DOMAIN, 39 | update_interval=timedelta(hours=6), 40 | ) 41 | LOGGER.debug("Initializing client VeoliaAPI") 42 | 43 | self.client_api = VeoliaAPI( 44 | username=self.config_entry.data[CONF_USERNAME], 45 | password=self.config_entry.data[CONF_PASSWORD], 46 | session=async_get_clientsession(hass), 47 | ) 48 | 49 | self._initial_historical_fetch = False 50 | 51 | async def _async_update_data(self) -> VeoliaModel: 52 | """Fetch and calculate data.""" 53 | try: 54 | now = datetime.now() 55 | 56 | if not self._initial_historical_fetch: 57 | # First init 58 | LOGGER.debug("Initial fetch 1 year") 59 | end_date = date(now.year, now.month, 1) 60 | start_date = date(end_date.year - 1, end_date.month, 1) 61 | self._initial_historical_fetch = True 62 | else: 63 | # Regular fetch 64 | LOGGER.debug("Periodic fetch - 2 months") 65 | start_date = date(now.year, now.month - 1, 1) 66 | end_date = date(now.year, now.month, 1) 67 | 68 | await self.client_api.fetch_all_data(start_date, end_date) 69 | account_data = self.client_api.account_data 70 | today = dt_util.now().date() 71 | return VeoliaModel.from_account_data(account_data, today=today) 72 | except VeoliaAPIError as exception: 73 | raise ConfigEntryAuthFailed(exception) from exception 74 | -------------------------------------------------------------------------------- /custom_components/veolia/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Veolia", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Veolia configuration", 7 | "description": "To test the compatibility of your Veolia account with this integration, please enter your postal code:", 8 | "data": { 9 | "postal_code": "Postal code" 10 | } 11 | }, 12 | "select_commune": { 13 | "title": "Select your commune", 14 | "description": "", 15 | "data": { 16 | "commune": "Please select your commune from the list below :" 17 | } 18 | }, 19 | "credentials": { 20 | "title": "Credentials", 21 | "description": "Good news, your Veolia account works with this integration. Please enter your login information that you use to log in to http://eau.veolia.fr :", 22 | "data": { 23 | "username": "Username", 24 | "password": "Password" 25 | } 26 | } 27 | }, 28 | "error": { 29 | "invalid_credentials": "Invalid username or password", 30 | "unknown": "Unable to connect, check the logs for more information", 31 | "commune_not_supported": "Sorry, your Veolia account is not supported. You must wait for the migration to the new site.", 32 | "no_communes_found": "No communes found for this postal code", 33 | "commune_not_veolia": "Veolia does not provide water in this commune", 34 | "no_account": "No Veolia account found for this postal code" 35 | }, 36 | "abort": { 37 | "already_configured": "This account is already configured" 38 | } 39 | }, 40 | "entity": { 41 | "sensor": { 42 | "veolia_index": { 43 | "name": "Consumption index" 44 | }, 45 | "daily_consumption": { 46 | "name": "Daily consumption" 47 | }, 48 | "monthly_consumption": { 49 | "name": "Monthly consumption" 50 | }, 51 | "annual_consumption": { 52 | "name": "Annual consumption" 53 | }, 54 | "last_consumption_date": { 55 | "name": "Last reading" 56 | } 57 | }, 58 | "switch": { 59 | "daily_sms_alert_switch": { 60 | "name": "SMS daily alert" 61 | }, 62 | "monthly_sms_alert_switch": { 63 | "name": "SMS monthly alert" 64 | }, 65 | "unoccupied_alert_switch": { 66 | "name": "Unoccupied alert" 67 | } 68 | }, 69 | "text": { 70 | "daily_threshold_text": { 71 | "name": "Daily threshold alert" 72 | }, 73 | "monthly_threshold_text": { 74 | "name": "Monthly threshold alert" 75 | } 76 | }, 77 | "binary_sensor": { 78 | "daily_alert_binary_sensor": { 79 | "name": "Daily consumption alert" 80 | }, 81 | "monthly_alert_binary_sensor": { 82 | "name": "Monthly consumption alert" 83 | }, 84 | "unoccupied_alert_binary_sensor": { 85 | "name": "Unoccupied alert" 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /custom_components/veolia/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Veolia", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Configuration Veolia", 7 | "description": "Afin de tester la compatibilité de votre compte Veolia avec cette intégration, veuillez saisir votre code postal :", 8 | "data": { 9 | "postal_code": "Code postal" 10 | } 11 | }, 12 | "select_commune": { 13 | "title": "Sélection de la commune", 14 | "description": "", 15 | "data": { 16 | "commune": "Veuillez sélectionner votre commune dans la liste ci-dessous :" 17 | } 18 | }, 19 | "credentials": { 20 | "title": "Informations d'identification", 21 | "description": "Bonne nouvelle votre compte Veolia fonctionne avec cette intégration. Veuillez entrer vos informations de connexion que vous utilisez pour vous connecter sur http://eau.veolia.fr :", 22 | "data": { 23 | "username": "Nom d'utilisateur", 24 | "password": "Mot de passe" 25 | } 26 | } 27 | }, 28 | "error": { 29 | "invalid_credentials": "Nom d'utilisateur ou mot de passe incorrect", 30 | "unknown": "Impossible de se connecter, consultez les journaux pour plus d'informations", 31 | "commune_not_supported": "Désolé votre compte Veolia n'est pas supportée par cette intégration. Vous devez attendre la migration vers le nouveau site (cette étape dépend uniquement de Veolia) Une fois la migration effectuée, veuillez réessayer.", 32 | "no_communes_found": "Aucune commune trouvée pour ce code postal", 33 | "commune_not_veolia": "Veolia ne fournit pas d'eau dans cette commune", 34 | "no_account": "Aucun compte Veolia trouvé pour ce code postal" 35 | }, 36 | "abort": { 37 | "already_configured": "Ce compte est déjà configuré" 38 | } 39 | }, 40 | "entity": { 41 | "sensor": { 42 | "veolia_index": { 43 | "name": "Index compteur" 44 | }, 45 | "daily_consumption": { 46 | "name": "Conso journalière" 47 | }, 48 | "monthly_consumption": { 49 | "name": "Conso mensuelle" 50 | }, 51 | "annual_consumption": { 52 | "name": "Conso annuelle" 53 | }, 54 | "last_consumption_date": { 55 | "name": "Dernier relevé" 56 | } 57 | }, 58 | "switch": { 59 | "daily_sms_alert_switch": { 60 | "name": "SMS alerte journalière" 61 | }, 62 | "monthly_sms_alert_switch": { 63 | "name": "SMS alerte mensuelle" 64 | }, 65 | "unoccupied_alert_switch": { 66 | "name": "Alerte logement vide" 67 | } 68 | }, 69 | "text": { 70 | "daily_threshold_text": { 71 | "name": "Seuil alerte journalière (en L)" 72 | }, 73 | "monthly_threshold_text": { 74 | "name": "Seuil alerte mensuelle (en M3)" 75 | } 76 | }, 77 | "binary_sensor": { 78 | "daily_alert_binary_sensor": { 79 | "name": "Alerte conso journalière" 80 | }, 81 | "monthly_alert_binary_sensor": { 82 | "name": "Alerte conso mensuelle" 83 | }, 84 | "unoccupied_alert_binary_sensor": { 85 | "name": "Alerte logement vide" 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /custom_components/veolia/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for veolia integration.""" 2 | 3 | import aiohttp 4 | from veolia_api import VeoliaAPI 5 | from veolia_api.exceptions import VeoliaAPIAuthError, VeoliaAPIInvalidCredentialsError 6 | import voluptuous as vol 7 | 8 | from homeassistant import config_entries 9 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME 10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 11 | 12 | from .const import DOMAIN, LOGGER 13 | 14 | 15 | class VeoliaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 16 | """Config flow for veolia.""" 17 | 18 | VERSION = 1 19 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 20 | 21 | def __init__(self) -> None: 22 | """Initialize.""" 23 | self._errors = {} 24 | self._postal_code = None 25 | self._communes = [] 26 | 27 | async def async_step_user(self, user_input=None) -> dict: 28 | """Handle a flow initialized by the user.""" 29 | self._errors = {} 30 | 31 | if user_input is not None: 32 | self._postal_code = user_input["postal_code"] 33 | return await self.async_step_select_commune() 34 | 35 | return self.async_show_form( 36 | step_id="user", 37 | data_schema=vol.Schema({vol.Required("postal_code"): str}), 38 | errors=self._errors, 39 | ) 40 | 41 | async def async_step_select_commune(self, user_input=None) -> dict: 42 | """Handle the selection of a commune.""" 43 | LOGGER.debug("Check city postal to for integration compatibility") 44 | if user_input is not None: 45 | selected_commune = next( 46 | ( 47 | commune 48 | for commune in self._communes 49 | if commune["libelle"] == user_input["commune"] 50 | ), 51 | None, 52 | ) 53 | if selected_commune["type_commune"] == "NON_REDIRIGE": 54 | return await self.async_step_credentials() 55 | 56 | if selected_commune["type_commune"] == "NON_DESSERVIE": 57 | self._errors["base"] = "commune_not_veolia" 58 | else: 59 | self._errors["base"] = "commune_not_supported" 60 | 61 | async with ( 62 | aiohttp.ClientSession() as session, 63 | session.get( 64 | f"https://prd-ael-sirius-refcommunes.istefr.fr/communes-nationales?q={self._postal_code}" 65 | ) as response, 66 | ): 67 | self._communes = await response.json() 68 | 69 | if not self._communes: 70 | self._errors["base"] = "no_communes_found" 71 | 72 | commune_options = { 73 | commune["libelle"]: commune["libelle"] for commune in self._communes 74 | } 75 | 76 | return self.async_show_form( 77 | step_id="select_commune", 78 | data_schema=vol.Schema({vol.Required("commune"): vol.In(commune_options)}), 79 | errors=self._errors, 80 | ) 81 | 82 | async def async_step_credentials(self, user_input=None) -> dict: 83 | """Handle the input of credentials.""" 84 | LOGGER.debug("Request credentials") 85 | if user_input is not None: 86 | try: 87 | api = VeoliaAPI( 88 | user_input[CONF_USERNAME], 89 | user_input[CONF_PASSWORD], 90 | async_get_clientsession(self.hass), 91 | ) 92 | valid = await api.login() 93 | 94 | if valid: 95 | return self.async_create_entry( 96 | title=user_input[CONF_USERNAME], 97 | data=user_input, 98 | ) 99 | except (VeoliaAPIAuthError, VeoliaAPIInvalidCredentialsError): 100 | self._errors["base"] = "invalid_credentials" 101 | except Exception: # noqa: BLE001 102 | LOGGER.debug("Unknown exception") 103 | self._errors["base"] = "unknown" 104 | 105 | return await self._show_credentials_form(user_input) 106 | 107 | return await self._show_credentials_form(user_input) 108 | 109 | async def _show_credentials_form(self, user_input) -> dict: 110 | """Show the configuration form to input credentials.""" 111 | return self.async_show_form( 112 | step_id="credentials", 113 | data_schema=vol.Schema( 114 | {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}, 115 | ), 116 | errors=self._errors, 117 | ) 118 | -------------------------------------------------------------------------------- /custom_components/veolia/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """The Veolia binary sensor integration.""" 2 | 3 | from homeassistant.components.binary_sensor import BinarySensorEntity 4 | 5 | from .const import DOMAIN, LOGGER, NAME 6 | 7 | 8 | async def async_setup_entry(hass, entry, async_add_devices) -> None: 9 | """Set up switch platform.""" 10 | LOGGER.debug("Setting up binary_sensor platform") 11 | coordinator = hass.data[DOMAIN][entry.entry_id] 12 | switches = [ 13 | DailyAlerts(coordinator, entry), 14 | MonthlyAlerts(coordinator, entry), 15 | UnoccupiedAlert(coordinator, entry), 16 | ] 17 | async_add_devices(switches) 18 | 19 | 20 | class DailyAlerts(BinarySensorEntity): 21 | """Representation of the first alert binary sensor.""" 22 | 23 | def __init__(self, coordinator, config_entry) -> None: 24 | """Initialize the entity.""" 25 | self.coordinator = coordinator 26 | self.config_entry = config_entry 27 | 28 | @property 29 | def device_info(self) -> dict: 30 | """Return device registry information for this entity.""" 31 | return { 32 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 33 | "manufacturer": NAME, 34 | "name": f"{NAME} {self.coordinator.data.id_abonnement}", 35 | } 36 | 37 | @property 38 | def unique_id(self) -> str: 39 | """Return a unique ID to use for this entity.""" 40 | return f"{self.config_entry.entry_id}_daily_alert_binary_sensor" 41 | 42 | @property 43 | def has_entity_name(self) -> bool: 44 | """Indicate that entity has name defined.""" 45 | return True 46 | 47 | @property 48 | def translation_key(self) -> str: 49 | """Translation key for this entity.""" 50 | return "daily_alert_binary_sensor" 51 | 52 | @property 53 | def icon(self) -> str: 54 | """Return the icon of the binary sensor.""" 55 | if bool(self.coordinator.data.alert_settings.daily_enabled): 56 | return "mdi:bell-check" 57 | return "mdi:bell-cancel" 58 | 59 | @property 60 | def is_on(self) -> bool: 61 | """Return true if the binary sensor is on.""" 62 | return self.coordinator.data.alert_settings.daily_enabled 63 | 64 | @property 65 | def available(self) -> bool: 66 | """Return true if the binary sensor is available.""" 67 | return not ( 68 | self.coordinator.data.alert_settings.daily_enabled 69 | and self.coordinator.data.alert_settings.daily_threshold == 0 70 | ) 71 | 72 | 73 | class MonthlyAlerts(BinarySensorEntity): 74 | """Representation of the second alert binary sensor.""" 75 | 76 | def __init__(self, coordinator, config_entry) -> None: 77 | """Initialize the entity.""" 78 | self.coordinator = coordinator 79 | self.config_entry = config_entry 80 | 81 | @property 82 | def device_info(self) -> dict: 83 | """Return device registry information for this entity.""" 84 | return { 85 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 86 | "manufacturer": NAME, 87 | "name": f"{NAME} {self.coordinator.data.id_abonnement}", 88 | } 89 | 90 | @property 91 | def unique_id(self) -> str: 92 | """Return a unique ID to use for this entity.""" 93 | return f"{self.config_entry.entry_id}_monthly_alert_binary_sensor" 94 | 95 | @property 96 | def has_entity_name(self) -> bool: 97 | """Indicate that entity has name defined.""" 98 | return True 99 | 100 | @property 101 | def translation_key(self) -> str: 102 | """Translation key for this entity.""" 103 | return "monthly_alert_binary_sensor" 104 | 105 | @property 106 | def icon(self) -> str: 107 | """Return the icon of the binary sensor.""" 108 | if bool(self.coordinator.data.alert_settings.monthly_enabled): 109 | return "mdi:bell-check" 110 | return "mdi:bell-cancel" 111 | 112 | @property 113 | def is_on(self) -> bool: 114 | """Return true if the binary sensor is on.""" 115 | return bool(self.coordinator.data.alert_settings.monthly_enabled) 116 | 117 | @property 118 | def available(self) -> bool: 119 | """Return true if the binary sensor is available.""" 120 | return not ( 121 | self.coordinator.data.alert_settings.daily_enabled 122 | and self.coordinator.data.alert_settings.daily_threshold == 0 123 | ) 124 | 125 | 126 | class UnoccupiedAlert(BinarySensorEntity): 127 | """Representation of the unoccupied alert binary sensor.""" 128 | 129 | def __init__(self, coordinator, config_entry) -> None: 130 | """Initialize the entity.""" 131 | self.coordinator = coordinator 132 | self.config_entry = config_entry 133 | 134 | @property 135 | def device_info(self) -> dict: 136 | """Return device registry information for this entity.""" 137 | return { 138 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 139 | "manufacturer": NAME, 140 | "name": f"{NAME} {self.coordinator.data.id_abonnement}", 141 | } 142 | 143 | @property 144 | def unique_id(self) -> str: 145 | """Return a unique ID to use for this entity.""" 146 | return f"{self.config_entry.entry_id}_unoccupied_alert_binary_sensor" 147 | 148 | @property 149 | def has_entity_name(self) -> bool: 150 | """Indicate that entity has name defined.""" 151 | return True 152 | 153 | @property 154 | def translation_key(self) -> str: 155 | """Translation key for this entity.""" 156 | return "unoccupied_alert_binary_sensor" 157 | 158 | @property 159 | def icon(self) -> str: 160 | """Return the icon of the binary sensor.""" 161 | if self.is_on: 162 | return "mdi:bell-check" 163 | return "mdi:bell-cancel" 164 | 165 | @property 166 | def is_on(self) -> bool: 167 | """Return true if the binary sensor is on.""" 168 | return ( 169 | self.coordinator.data.alert_settings.daily_enabled 170 | and self.coordinator.data.alert_settings.daily_threshold == 0 171 | ) 172 | -------------------------------------------------------------------------------- /custom_components/veolia/text.py: -------------------------------------------------------------------------------- 1 | """Text entities for Veolia integration.""" 2 | 3 | from dataclasses import asdict 4 | 5 | from homeassistant.components.text import TextEntity 6 | 7 | from .const import DOMAIN, LOGGER, NAME 8 | 9 | 10 | async def async_setup_entry(hass, entry, async_add_entities) -> None: 11 | """Set up text platform.""" 12 | LOGGER.debug("Setting up text platform") 13 | coordinator = hass.data[DOMAIN][entry.entry_id] 14 | texts = [ 15 | DailyThresholdText(coordinator, entry), 16 | MonthlyThresholdText(coordinator, entry), 17 | ] 18 | async_add_entities(texts) 19 | 20 | 21 | class DailyThresholdText(TextEntity): 22 | """Representation of the daily threshold text entity.""" 23 | 24 | def __init__(self, coordinator, config_entry) -> None: 25 | """Initialize the entity.""" 26 | self.coordinator = coordinator 27 | self.config_entry = config_entry 28 | 29 | @property 30 | def device_info(self) -> dict: 31 | """Return device registry information for this entity.""" 32 | return { 33 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 34 | "manufacturer": NAME, 35 | "name": f"{NAME} {self.coordinator.data.id_abonnement}", 36 | } 37 | 38 | @property 39 | def unique_id(self) -> str: 40 | """Return a unique ID to use for this entity.""" 41 | return f"{self.config_entry.entry_id}_daily_threshold_text" 42 | 43 | @property 44 | def has_entity_name(self) -> bool: 45 | """Indicate that entity has name defined.""" 46 | return True 47 | 48 | @property 49 | def translation_key(self) -> str: 50 | """Translation key for this entity.""" 51 | return "daily_threshold_text" 52 | 53 | @property 54 | def native_max(self) -> int: 55 | """Max number of characters.""" 56 | return 6 57 | 58 | @property 59 | def native_min(self) -> int: 60 | """Min number of characters.""" 61 | return 1 62 | 63 | @property 64 | def pattern(self) -> str: 65 | """Check validity with regex pattern.""" 66 | return "^(?:0|[1-9][0-9]{2,3}|10000)$" 67 | 68 | @property 69 | def icon(self) -> str: 70 | """Return the icon of the text entity.""" 71 | return "mdi:water-alert" 72 | 73 | @property 74 | def available(self) -> bool: 75 | """Return true if the text entity is available.""" 76 | return not ( 77 | self.coordinator.data.alert_settings.daily_enabled 78 | and self.coordinator.data.alert_settings.daily_threshold == 0 79 | ) 80 | 81 | @property 82 | def native_value(self) -> str: 83 | """Return the current threshold value.""" 84 | return str(self.coordinator.data.alert_settings.daily_threshold or 0) 85 | 86 | async def async_set_value(self, value: str) -> None: 87 | """Set the threshold value.""" 88 | if int(value) == 0: 89 | self.coordinator.data.alert_settings.daily_enabled = False 90 | else: 91 | self.coordinator.data.alert_settings.daily_enabled = True 92 | self.coordinator.data.alert_settings.daily_threshold = value 93 | self.coordinator.data.alert_settings.daily_notif_email = True 94 | self.coordinator.data.alert_settings.daily_notif_sms = False 95 | 96 | LOGGER.debug( 97 | "Setting daily threshold to %s", 98 | asdict(self.coordinator.data.alert_settings), 99 | ) 100 | res = await self.coordinator.client_api.set_alerts_settings( 101 | self.coordinator.data.alert_settings 102 | ) 103 | if not res: 104 | message = f"Failed to set alert= {self.__class__.__qualname__} settings= {asdict(self.coordinator.data.alert_settings)}" 105 | raise RuntimeError(message) 106 | await self.coordinator.async_request_refresh() 107 | self.async_write_ha_state() 108 | 109 | 110 | class MonthlyThresholdText(TextEntity): 111 | """Representation of the monthly threshold text entity.""" 112 | 113 | def __init__(self, coordinator, config_entry) -> None: 114 | """Initialize the entity.""" 115 | self.coordinator = coordinator 116 | self.config_entry = config_entry 117 | 118 | @property 119 | def device_info(self) -> dict: 120 | """Return device registry information for this entity.""" 121 | return { 122 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 123 | "manufacturer": NAME, 124 | "name": f"{NAME} {self.coordinator.data.id_abonnement}", 125 | } 126 | 127 | @property 128 | def unique_id(self) -> str: 129 | """Return a unique ID to use for this entity.""" 130 | return f"{self.config_entry.entry_id}_monthly_threshold_text" 131 | 132 | @property 133 | def has_entity_name(self) -> bool: 134 | """Indicate that entity has name defined.""" 135 | return True 136 | 137 | @property 138 | def translation_key(self) -> str: 139 | """Translation key for this entity.""" 140 | return "monthly_threshold_text" 141 | 142 | @property 143 | def native_max(self) -> int: 144 | """Max number of characters.""" 145 | return 4 146 | 147 | @property 148 | def native_min(self) -> int: 149 | """Min number of characters.""" 150 | return 1 151 | 152 | @property 153 | def pattern(self) -> str: 154 | """Check validity with regex pattern.""" 155 | return "^(?:0|[1-9][0-9]{0,2}|1000)$" 156 | 157 | @property 158 | def icon(self) -> str: 159 | """Return the icon of the text entity.""" 160 | return "mdi:water-alert" 161 | 162 | @property 163 | def available(self) -> bool: 164 | """Return true if the text entity is available.""" 165 | return not ( 166 | self.coordinator.data.alert_settings.daily_enabled 167 | and self.coordinator.data.alert_settings.daily_threshold == 0 168 | ) 169 | 170 | @property 171 | def native_value(self) -> str: 172 | """Return the current threshold value.""" 173 | return str(self.coordinator.data.alert_settings.monthly_threshold or 0) 174 | 175 | async def async_set_value(self, value: str) -> None: 176 | """Set the threshold value.""" 177 | if int(value) == 0: 178 | self.coordinator.data.alert_settings.monthly_enabled = False 179 | else: 180 | self.coordinator.data.alert_settings.monthly_enabled = True 181 | self.coordinator.data.alert_settings.monthly_threshold = value 182 | self.coordinator.data.alert_settings.monthly_notif_email = True 183 | self.coordinator.data.alert_settings.monthly_notif_sms = False 184 | 185 | LOGGER.debug( 186 | "Setting monthly threshold to %s", 187 | asdict(self.coordinator.data.alert_settings), 188 | ) 189 | res = await self.coordinator.client_api.set_alerts_settings( 190 | self.coordinator.data.alert_settings 191 | ) 192 | if not res: 193 | message = f"Failed to set alert= {self.__class__.__qualname__} settings= {asdict(self.coordinator.data.alert_settings)}" 194 | raise RuntimeError(message) 195 | await self.coordinator.async_request_refresh() 196 | self.async_write_ha_state() 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![GitHub Release][releases-shield]][releases] 4 | [![HACS Default](https://img.shields.io/badge/HACS-Default-blue.svg?style=for-the-badge)](https://hacs.xyz/docs/faq/custom_repositories) 5 | 6 | > [!IMPORTANT] 7 | > 8 | > ## Message important concernant l’avenir de cette intégration : 9 | > 10 | > Ayant récemment déménagé dans une zone non couverte par Veolia, je n’ai désormais plus accès à un compte Veolia actif. 11 | > 12 | > Cela rend difficile le maintien, les tests et l’évolution du projet dans de bonnes conditions. 13 | > Je continuerai autant que possible à assurer le support, les corrections de bugs et la maintenance générale. 14 | > 15 | > Toutefois, pour garantir la pérennité de l’intégration et permettre son évolution, je recherche un ou plusieurs développeurs motivés pouvant m’aider à la faire avancer, tester les nouveautés et contribuer aux futures améliorations. 16 | > 17 | > Si vous êtes intéressé(e) pour rejoindre le développement ou simplement donner un coup de main sur certaines fonctionnalités, n’hésitez pas à ouvrir une issue ou à me contacter. Toute contribution, même modeste, est la bienvenue. 18 | > 19 | > Merci d’avance pour votre aide et votre engagement ! 💙 20 | 21 | > ### UNIQUEMENT compatible avec le nouveau site de Veolia : https://www.eau.veolia.fr/ 22 | > 23 | > ### N'est PAS compatible avec les sous domaines suivant : https://service.eau.veolia.fr & https://espace-client.vedif.eau.veolia.fr 24 | 25 | --- 26 | 27 |

28 | Buy Me A Coffee 29 |

30 | 31 | ## Informations disponibles 32 | 33 | **Cette intégration configurera les plateformes suivantes.** 34 | 35 | | Plateforme | Description | 36 | | --------------- | --------------------------------------------------- | 37 | | `sensor` | Affiche les informations de l'API Veolia | 38 | | `switch` | Switch d'activation/désactivation des alertes conso | 39 | | `text` | Saisie des valeurs de réglages des alertes | 40 | | `binary_sensor` | Affiche l'états des alertes conso | 41 | 42 | ### Données disponibles 43 | 44 | - Consommation d'eau (journalière, mensuelle) 45 | - Index de consommation d'eau 46 | - Seuils d'alertes de consommation d'eau 47 | - Etat des alertes de consommation d'eau 48 | - Date de la dernière relève de consommation d'eau 49 | 50 | > #### **Note :** Les données de l'intégration sont mises à jour toutes les 12h. 51 | 52 | ### Capteurs : 53 | 54 | 55 | 56 | ### Contrôles : 57 | 58 | 59 | 60 | ### Configuration des alertes 61 | 62 | L'intégration Veolia permet de configurer des alertes de consommation d'eau pour surveiller votre utilisation 63 | quotidienne et mensuelle, et même pour détecter une fuite si vous n'êtes pas chez vous. 64 | 65 | Les alertes sont activées ou désactivées, en renseignant les champs seuils d'alertes (0 = désactivé, >0 = activé) 66 | 67 | Il existe 3 types d'alertes : 68 | 69 | - Alerte journalière 70 | - L'alerte journalière est une alerte qui se déclenche si votre consommation d'eau quotidienne dépasse un certain seuil **cette valeur est en litre, le minimum est de 100 litres.** 71 | - Alerte mensuelle 72 | - L'alerte mensuelle est une alerte qui se déclenche si votre consommation d'eau mensuelle dépasse un certain seuil **cette valeur est en metre cubes, le minimum est de 1m3**. 73 | - Alerte logement "vide" 74 | - L'alerte logement vide est une alerte qui se déclenche si une consommation d'eau est détectée alors que vous n'êtes pas chez vous. 75 | 76 | Informations supplémentaires : 77 | 78 | > Les notifications d'alerte sont envoyées par Veolia directement par email et par SMS (aux coordonnées de contact renseigné dans votre compte Veolia). 79 | 80 | > Il n'est pas possible de désactiver les notifications d'alerte par email, mais vous pouvez choisir d'activer ou pas les notifications par SMS, uniquement si un seuil est renseigné. 81 | 82 | ### Visualisation des données de consommation 83 | 84 | L'intégration Veolia permet de visualiser les données de consommation d'eau en natif dans Home Assistant. Elle re-télécharge l'historique du mois en cours depuis Véolia et met à jour la base de données Home Assistant. 85 | 86 | #### 1. Ajout au dashboard energie de Home Assistant 87 | 88 | 89 | 90 | Pour ajouter la consommation d'eau au dashboard energie de Home Assistant, allez `Energie` -> 3 petits points en haut à droite -> `Configuration de l'energie` -> `Ajouter une source d'eau` -> Dans le champ `Consommation d'eau` choissisez `sensor.veolia_index_compteur` 91 | 92 | 93 | 94 | #### 2. Ajout d'une carte de consommation d'eau journalière 95 | 96 | 97 | 98 | Pour ajouter la carte de consommation d'eau journalière, sur votre dashboard, cliquez sur `Ajouter une carte` puis selectionner `Graphique des statistiques` et choissisez l'entité `sensor.veolia_consommation_journaliere`, configurer la carte comme l'exemple ci-dessous : 99 | 100 | 101 | 102 | > #### **Note :** La carte Graphique des statistiques ne fonctionnera qu'avec le sensor `sensor.veolia_consommation_journaliere` 103 | 104 | ## Installation 105 | 106 | ### Via [HACS](https://hacs.xyz/) (recommandé) 107 | 108 | **Cliquez ici:** 109 | 110 | [![Open your Home Assistant instance and open the repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg?style=flat-square)](https://my.home-assistant.io/redirect/hacs_repository/?owner=Jezza34000&repository=homeassistant_veolia&category=integration) 111 | 112 | **ou suivez ces étapes:** 113 | 114 | 1. Ouvrez HACS (Home Assistant Community Store) 115 | 2. Cliquez sur les trois points en haut à droite 116 | 3. Cliquez sur `Dépôts personnalisées` 117 | 4. Dans le champ `Dépôt` entrez https://github.com/Jezza34000/homeassistant_veolia/ 118 | 5. Dans le champ `Type` sélectionnez `Intégration` 119 | 6. Cliquez sur `Ajouter` 120 | 7. Recherchez `Veolia` dans la liste des intégrations 121 | 8. Installez l'intégration 122 | 9. Redémarrez Home Assistant 123 | 10. Ouvrez paramètres -> intégrations -> ajouter une intégration -> recherchez `Veolia` 124 | 11. Suivez les instructions pour configurer l'intégration 125 | 126 | ### Manuellement 127 | 128 | 1. Copiez le dossier `custom_components/veolia` dans le dossier `custom_components` de votre configuration Home Assistant. 129 | 2. Redémarrez Home Assistant 130 | 3. Ouvrez paramètres -> intégrations -> ajouter une intégration -> recherchez `Veolia` 131 | 4. Suivez les instructions pour configurer l'intégration 132 | 133 | ## Bug et demande de fonctionnalités 134 | 135 | - [Cliquez-ici](https://github.com/Jezza34000/homeassistant_veolia/issues) 136 | 137 | ## API Veolia 138 | 139 | Cette intégration utilise mon client API Veolia disponible ici : [veolia-api](https://github.com/Jezza34000/veolia-api). 140 | 141 | ## Credits 142 | 143 | Le modèle de code de cette intégration à principalement été tiré du blueprint de @Ludeeus. Merci à lui pour son travail. 144 | 145 | [![GitHub Activity][commits-shield]][commits] 146 | [![License][license-shield]](LICENSE) 147 | ![Project Maintenance][maintenance-shield] 148 | 149 | --- 150 | 151 | 152 | 153 | [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge 154 | [hacs]: https://hacs.xyz 155 | [releases-shield]: https://img.shields.io/github/v/release/Jezza34000/homeassistant_veolia.svg?style=for-the-badge 156 | [releases]: https://github.com/Jezza34000/homeassistant_veolia/releases 157 | [commits-shield]: https://img.shields.io/github/commit-activity/y/ludeeus/integration_blueprint.svg?style=for-the-badge 158 | [commits]: https://github.com/Jezza34000/homeassistant_veolia/commits/main 159 | [license-shield]: https://img.shields.io/github/license/ludeeus/integration_blueprint.svg?style=for-the-badge 160 | [maintenance-shield]: https://img.shields.io/badge/maintainer-%20%40Jezza34000-blue.svg?style=for-the-badge 161 | [sensorsimg]: images/entities.png 162 | -------------------------------------------------------------------------------- /custom_components/veolia/model.py: -------------------------------------------------------------------------------- 1 | """Veolia model.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections.abc import Iterable 6 | from dataclasses import dataclass 7 | from datetime import date, datetime, timedelta, timezone 8 | from typing import Any 9 | 10 | from .const import ( 11 | CONSO, 12 | CONSO_FIABILITY, 13 | CUBIC_METER, 14 | DATA_DATE, 15 | IDX, 16 | IDX_FIABILITY, 17 | LITRE, 18 | LOGGER, 19 | MONTH, 20 | YEAR, 21 | ) 22 | 23 | 24 | def _safe_last(seq: Iterable[Any]) -> Any | None: 25 | """Get last data.""" 26 | try: 27 | s = list(seq) if not isinstance(seq, list) else seq 28 | return s[-1] if s else None 29 | except Exception: 30 | return None 31 | 32 | 33 | def _parse_date(s: str) -> date | None: 34 | """Parse date.""" 35 | try: 36 | return datetime.strptime(s, "%Y-%m-%d").date() 37 | except Exception: 38 | return None 39 | 40 | 41 | def _find_last_for_date(records: list[dict], d: date) -> dict | None: 42 | """Find last available data for date.""" 43 | for rec in reversed(records or []): 44 | if _parse_date(rec.get(DATA_DATE, "")) == d: 45 | return rec 46 | return None 47 | 48 | 49 | @dataclass(slots=True) 50 | class VeoliaComputed: 51 | """Veolia computed data.""" 52 | 53 | last_index_m3: float | None 54 | last_daily_liters: int | None 55 | last_daily_m3: float | None 56 | monthly_latest_m3: float | None 57 | annual_total_m3: float | None 58 | last_date: date | None 59 | daily_fiability: str | None 60 | monthly_fiability: str | None 61 | daily_stats_liters: list[dict] 62 | monthly_stats_cubic_meters: list[dict] 63 | index_stats_m3: list[dict] 64 | daily_today_liters: int | None 65 | daily_today_m3: float | None 66 | daily_today_fiability: str | None 67 | 68 | 69 | @dataclass(slots=True) 70 | class VeoliaModel: 71 | """VeoliaModel.""" 72 | 73 | raw: Any # VeoliaAccountData 74 | computed: VeoliaComputed 75 | 76 | def __getattr__(self, name: str): 77 | """GetAttr for Switch and BinarySensor.""" 78 | return getattr(self.raw, name) 79 | 80 | @staticmethod 81 | def from_account_data(raw: Any, *, today: date | None = None) -> VeoliaModel: 82 | """Read data and populate VeoliaComputed model.""" 83 | daily = raw.daily_consumption or [] 84 | monthly = raw.monthly_consumption or [] 85 | last_daily = _safe_last(daily) or {} 86 | last_month = _safe_last(monthly) or {} 87 | last_index_m3 = (last_daily.get(IDX) or {}).get(CUBIC_METER) or ( 88 | last_month.get(IDX) or {} 89 | ).get(CUBIC_METER) 90 | last_index_m3 = float(last_index_m3) if last_index_m3 is not None else None 91 | last_daily_liters = (last_daily.get(CONSO) or {}).get(LITRE) 92 | last_daily_liters = ( 93 | int(last_daily_liters) if last_daily_liters is not None else None 94 | ) 95 | last_daily_m3 = (last_daily.get(CONSO) or {}).get(CUBIC_METER) 96 | last_daily_m3 = float(last_daily_m3) if last_daily_m3 is not None else None 97 | monthly_latest_m3 = (last_month.get(CONSO) or {}).get(CUBIC_METER) 98 | monthly_latest_m3 = ( 99 | float(monthly_latest_m3) if monthly_latest_m3 is not None else None 100 | ) 101 | 102 | try: 103 | current_year = datetime.now().year 104 | annual_total_m3 = float( 105 | sum( 106 | float((m.get(CONSO) or {}).get(CUBIC_METER) or 0.0) 107 | for m in monthly 108 | if m.get(YEAR) == current_year 109 | ) 110 | ) 111 | except Exception: 112 | annual_total_m3 = None 113 | 114 | d_last = (last_daily or {}).get(DATA_DATE) 115 | last_date = _parse_date(d_last) if d_last else None 116 | daily_fiability = (last_daily or {}).get(IDX_FIABILITY) 117 | monthly_fiability = (last_month or {}).get(CONSO_FIABILITY) 118 | if today is None: 119 | today = datetime.now().date() 120 | rec_today = _find_last_for_date(daily, today) 121 | if rec_today: 122 | _c = rec_today.get(CONSO) or {} 123 | daily_today_liters = int(_c.get(LITRE) or 0) 124 | daily_today_m3 = float(_c.get(CUBIC_METER) or 0.0) 125 | daily_today_fiability = rec_today.get(IDX_FIABILITY) 126 | else: 127 | daily_today_liters = None 128 | daily_today_m3 = None 129 | daily_today_fiability = None 130 | # Recorder data 131 | daily_stats_liters: list[dict] = [] 132 | monthly_stats_cubic_meters: list[dict] = [] 133 | index_stats_m3: list[dict] = [] 134 | try: 135 | # Statistics for Daily 136 | cumul_liters = 0 137 | for rec in daily: 138 | date_str = rec.get(DATA_DATE) 139 | if not date_str: 140 | continue 141 | d = datetime.strptime(date_str, "%Y-%m-%d") 142 | start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=timezone.utc) 143 | liters = int((rec.get(CONSO) or {}).get(LITRE) or 0) 144 | cumul_liters += liters 145 | daily_stats_liters.append( 146 | {"start": start, "state": liters, "sum": cumul_liters} 147 | ) 148 | # Statistics for Monthly 149 | cumul_cubic_meter = 0 150 | for rec in monthly: 151 | year = rec.get(YEAR) 152 | month = rec.get(MONTH) 153 | if not year or not month: 154 | continue 155 | date_str = f"{year}-{month}-{1}" 156 | d = datetime.strptime(date_str, "%Y-%m-%d") 157 | start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=timezone.utc) 158 | cubic_meter = float((rec.get(CONSO) or {}).get(CUBIC_METER) or 0) 159 | cumul_cubic_meter += cubic_meter 160 | monthly_stats_cubic_meters.append( 161 | {"start": start, "state": cubic_meter, "sum": cumul_cubic_meter} 162 | ) 163 | last_state = None 164 | last_sum = None 165 | last_date = None 166 | LOGGER.debug("Computing index_stats_m3 with data = %s", daily) 167 | for record in daily: 168 | date_str = record.get(DATA_DATE) 169 | if not date_str: 170 | continue 171 | try: 172 | d = datetime.strptime(date_str, "%Y-%m-%d").date() 173 | except ValueError: 174 | continue 175 | idx = (record.get(IDX) or {}).get(CUBIC_METER) 176 | try: 177 | cur_state = float(idx) if idx is not None else None 178 | except (TypeError, ValueError): 179 | continue 180 | if cur_state is None: 181 | continue 182 | start_dt = datetime( 183 | d.year, d.month, d.day, 0, 0, 0, tzinfo=timezone.utc 184 | ) 185 | cur_sum = cur_state 186 | # Forward-fill 187 | if last_date is not None: 188 | gap = (d - last_date).days 189 | if gap > 1 and last_state is not None and last_sum is not None: 190 | for i in range(1, gap): 191 | fill_d = last_date + timedelta(days=i) 192 | fill_dt = datetime( 193 | fill_d.year, 194 | fill_d.month, 195 | fill_d.day, 196 | 0, 197 | 0, 198 | 0, 199 | tzinfo=timezone.utc, 200 | ) 201 | index_stats_m3.append( 202 | {"start": fill_dt, "state": last_state, "sum": last_sum} 203 | ) 204 | index_stats_m3.append( 205 | {"start": start_dt, "state": cur_state, "sum": cur_sum} 206 | ) 207 | last_state = cur_state 208 | last_sum = cur_sum 209 | last_date = d 210 | # Forward-fill until today 211 | if ( 212 | last_date is not None 213 | and last_state is not None 214 | and last_sum is not None 215 | ): 216 | today = datetime.now(timezone.utc).date() 217 | gap = (today - last_date).days 218 | if gap >= 1: 219 | for i in range(1, gap + 1): 220 | fill_d = last_date + timedelta(days=i) 221 | fill_dt = datetime( 222 | fill_d.year, 223 | fill_d.month, 224 | fill_d.day, 225 | 0, 226 | 0, 227 | 0, 228 | tzinfo=timezone.utc, 229 | ) 230 | index_stats_m3.append( 231 | {"start": fill_dt, "state": last_state, "sum": last_sum} 232 | ) 233 | except Exception as e: 234 | LOGGER.warning( 235 | "An exception occur when computing Statistics, details=%s", e 236 | ) 237 | daily_stats_liters = [] 238 | monthly_stats_cubic_meters = [] 239 | index_stats_m3 = [] 240 | comp = VeoliaComputed( 241 | last_index_m3=last_index_m3, 242 | last_daily_liters=last_daily_liters, 243 | last_daily_m3=last_daily_m3, 244 | monthly_latest_m3=monthly_latest_m3, 245 | annual_total_m3=annual_total_m3, 246 | last_date=last_date, 247 | daily_fiability=daily_fiability, 248 | monthly_fiability=monthly_fiability, 249 | daily_stats_liters=daily_stats_liters, 250 | monthly_stats_cubic_meters=monthly_stats_cubic_meters, 251 | index_stats_m3=index_stats_m3, 252 | daily_today_liters=daily_today_liters, 253 | daily_today_m3=daily_today_m3, 254 | daily_today_fiability=daily_today_fiability, 255 | ) 256 | return VeoliaModel(raw=raw, computed=comp) 257 | -------------------------------------------------------------------------------- /custom_components/veolia/switch.py: -------------------------------------------------------------------------------- 1 | """Switch platform for Veolia.""" 2 | 3 | from dataclasses import asdict 4 | 5 | from homeassistant.components.switch import SwitchEntity 6 | 7 | from .const import DOMAIN, LOGGER, NAME 8 | 9 | 10 | async def async_setup_entry(hass, entry, async_add_devices) -> None: 11 | """Set up switch platform.""" 12 | LOGGER.debug("Setting up switch platform") 13 | coordinator = hass.data[DOMAIN][entry.entry_id] 14 | switches = [ 15 | DailySMSAlerts(coordinator, entry), 16 | MonthlySMSAlerts(coordinator, entry), 17 | UnoccupiedAlertSwitch(coordinator, entry), 18 | ] 19 | async_add_devices(switches) 20 | 21 | 22 | class DailySMSAlerts(SwitchEntity): 23 | """Representation of the daily SMS alert switch.""" 24 | 25 | def __init__(self, coordinator, config_entry) -> None: 26 | """Initialize the entity.""" 27 | self.coordinator = coordinator 28 | self.config_entry = config_entry 29 | 30 | @property 31 | def device_info(self) -> dict: 32 | """Return device registry information for this entity.""" 33 | return { 34 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 35 | "manufacturer": NAME, 36 | "name": NAME, 37 | } 38 | 39 | @property 40 | def unique_id(self) -> str: 41 | """Return a unique ID to use for this entity.""" 42 | return f"{self.config_entry.entry_id}_daily_sms_alert_switch" 43 | 44 | @property 45 | def has_entity_name(self) -> bool: 46 | """Indicate that entity has name defined.""" 47 | return True 48 | 49 | @property 50 | def translation_key(self) -> str: 51 | """Translation key for this entity.""" 52 | return "daily_sms_alert_switch" 53 | 54 | @property 55 | def icon(self) -> str: 56 | """Return the icon of the switch.""" 57 | if bool(self.coordinator.data.alert_settings.daily_notif_sms): 58 | return "mdi:comment-check" 59 | return "mdi:comment-off" 60 | 61 | @property 62 | def is_on(self) -> bool: 63 | """Return true if the switch is on.""" 64 | return bool(self.coordinator.data.alert_settings.daily_notif_sms) 65 | 66 | @property 67 | def available(self) -> bool: 68 | """Return true if the switch is available.""" 69 | return ( 70 | not ( 71 | self.coordinator.data.alert_settings.daily_enabled 72 | and self.coordinator.data.alert_settings.daily_threshold == 0 73 | ) 74 | and self.coordinator.data.alert_settings.daily_enabled 75 | ) 76 | 77 | async def async_turn_on(self, **kwargs) -> None: 78 | """Turn the switch on.""" 79 | LOGGER.debug("Turning on %s", self.__class__.__qualname__) 80 | self.coordinator.data.alert_settings.daily_notif_sms = True 81 | res = await self.coordinator.client_api.set_alerts_settings( 82 | self.coordinator.data.alert_settings 83 | ) 84 | if not res: 85 | message = f"Failed to set alert= {self.__class__.__qualname__} settings= {asdict(self.coordinator.data.alert_settings)}" 86 | raise RuntimeError(message) 87 | await self.coordinator.async_request_refresh() 88 | self.async_write_ha_state() 89 | 90 | async def async_turn_off(self, **kwargs) -> None: 91 | """Turn the switch off.""" 92 | LOGGER.debug("Turning off %s", self.__class__.__qualname__) 93 | self.coordinator.data.alert_settings.daily_notif_sms = False 94 | res = await self.coordinator.client_api.set_alerts_settings( 95 | self.coordinator.data.alert_settings 96 | ) 97 | if not res: 98 | message = f"Failed to set alert= {self.__class__.__qualname__} settings= {asdict(self.coordinator.data.alert_settings)}" 99 | raise RuntimeError(message) 100 | await self.coordinator.async_request_refresh() 101 | self.async_write_ha_state() 102 | 103 | 104 | class MonthlySMSAlerts(SwitchEntity): 105 | """Representation of the monthly SMS alert switch.""" 106 | 107 | def __init__(self, coordinator, config_entry) -> None: 108 | """Initialize the entity.""" 109 | self.coordinator = coordinator 110 | self.config_entry = config_entry 111 | 112 | @property 113 | def device_info(self) -> dict: 114 | """Return device registry information for this entity.""" 115 | return { 116 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 117 | "manufacturer": NAME, 118 | "name": f"{NAME} {self.coordinator.data.id_abonnement}", 119 | } 120 | 121 | @property 122 | def unique_id(self) -> str: 123 | """Return a unique ID to use for this entity.""" 124 | return f"{self.config_entry.entry_id}_monthly_sms_alert_switch" 125 | 126 | @property 127 | def has_entity_name(self) -> bool: 128 | """Indicate that entity has name defined.""" 129 | return True 130 | 131 | @property 132 | def translation_key(self) -> str: 133 | """Translation key for this entity.""" 134 | return "monthly_sms_alert_switch" 135 | 136 | @property 137 | def icon(self) -> str: 138 | """Return the icon of the switch.""" 139 | if bool(self.coordinator.data.alert_settings.monthly_notif_sms): 140 | return "mdi:comment-check" 141 | return "mdi:comment-off" 142 | 143 | @property 144 | def is_on(self) -> bool: 145 | """Return true if the switch is on.""" 146 | return bool(self.coordinator.data.alert_settings.monthly_notif_sms) 147 | 148 | @property 149 | def available(self) -> bool: 150 | """Return true if the switch is available.""" 151 | return ( 152 | not ( 153 | self.coordinator.data.alert_settings.daily_enabled 154 | and self.coordinator.data.alert_settings.daily_threshold == 0 155 | ) 156 | and self.coordinator.data.alert_settings.monthly_enabled 157 | ) 158 | 159 | async def async_turn_on(self, **kwargs) -> None: 160 | """Turn the switch on.""" 161 | LOGGER.debug("Turning on %s", self.__class__.__qualname__) 162 | self.coordinator.data.alert_settings.monthly_notif_sms = True 163 | res = await self.coordinator.client_api.set_alerts_settings( 164 | self.coordinator.data.alert_settings 165 | ) 166 | if not res: 167 | message = f"Failed to set alert= {self.__class__.__qualname__} settings= {asdict(self.coordinator.data.alert_settings)}" 168 | raise RuntimeError(message) 169 | await self.coordinator.async_request_refresh() 170 | self.async_write_ha_state() 171 | 172 | async def async_turn_off(self, **kwargs) -> None: 173 | """Turn the switch off.""" 174 | LOGGER.debug("Turning off %s", self.__class__.__qualname__) 175 | self.coordinator.data.alert_settings.monthly_notif_sms = False 176 | res = await self.coordinator.client_api.set_alerts_settings( 177 | self.coordinator.data.alert_settings 178 | ) 179 | if not res: 180 | message = f"Failed to set alert= {self.__class__.__qualname__} settings= {asdict(self.coordinator.data.alert_settings)}" 181 | raise RuntimeError(message) 182 | await self.coordinator.async_request_refresh() 183 | self.async_write_ha_state() 184 | 185 | 186 | class UnoccupiedAlertSwitch(SwitchEntity): 187 | """Representation of the switch to activate the unoccupied alert.""" 188 | 189 | def __init__(self, coordinator, config_entry) -> None: 190 | """Initialize the entity.""" 191 | self.coordinator = coordinator 192 | self.config_entry = config_entry 193 | 194 | @property 195 | def device_info(self) -> dict: 196 | """Return device registry information for this entity.""" 197 | return { 198 | "identifiers": {(DOMAIN, self.config_entry.entry_id)}, 199 | "manufacturer": NAME, 200 | "name": f"{NAME} {self.coordinator.data.id_abonnement}", 201 | } 202 | 203 | @property 204 | def unique_id(self) -> str: 205 | """Return a unique ID to use for this entity.""" 206 | return f"{self.config_entry.entry_id}_unoccupied_alert_switch" 207 | 208 | @property 209 | def has_entity_name(self) -> bool: 210 | """Indicate that entity has name defined.""" 211 | return True 212 | 213 | @property 214 | def translation_key(self) -> str: 215 | """Translation key for this entity.""" 216 | return "unoccupied_alert_switch" 217 | 218 | @property 219 | def icon(self) -> str: 220 | """Return the icon of the switch.""" 221 | if ( 222 | bool(self.coordinator.data.alert_settings.daily_enabled) 223 | and self.coordinator.data.alert_settings.daily_threshold == 0 224 | ): 225 | return "mdi:comment-check" 226 | return "mdi:comment-off" 227 | 228 | @property 229 | def is_on(self) -> bool: 230 | """Return true if the switch is on.""" 231 | return ( 232 | self.coordinator.data.alert_settings.daily_enabled 233 | and self.coordinator.data.alert_settings.daily_threshold == 0 234 | ) 235 | 236 | async def async_turn_on(self, **kwargs) -> None: 237 | """Turn the switch on.""" 238 | LOGGER.debug("Turning on %s", self.__class__.__qualname__) 239 | self.coordinator.data.alert_settings.daily_enabled = True 240 | self.coordinator.data.alert_settings.daily_threshold = 0 241 | self.coordinator.data.alert_settings.daily_notif_sms = True 242 | self.coordinator.data.alert_settings.daily_notif_email = True 243 | res = await self.coordinator.client_api.set_alerts_settings( 244 | self.coordinator.data.alert_settings 245 | ) 246 | if not res: 247 | message = f"Failed to set alert= {self.__class__.__qualname__} settings= {asdict(self.coordinator.data.alert_settings)}" 248 | raise RuntimeError(message) 249 | await self.coordinator.async_request_refresh() 250 | self.async_write_ha_state() 251 | 252 | async def async_turn_off(self, **kwargs) -> None: 253 | """Turn the switch off.""" 254 | LOGGER.debug("Turning off %s", self.__class__.__qualname__) 255 | self.coordinator.data.alert_settings.daily_enabled = False 256 | res = await self.coordinator.client_api.set_alerts_settings( 257 | self.coordinator.data.alert_settings 258 | ) 259 | if not res: 260 | message = f"Failed to set alert= {self.__class__.__qualname__} settings= {asdict(self.coordinator.data.alert_settings)}" 261 | raise RuntimeError(message) 262 | await self.coordinator.async_request_refresh() 263 | self.async_write_ha_state() 264 | -------------------------------------------------------------------------------- /custom_components/veolia/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for Veolia.""" 2 | 3 | from homeassistant.components.recorder.statistics import ( 4 | StatisticMeanType, 5 | StatisticMetaData, 6 | async_import_statistics, 7 | ) 8 | from homeassistant.components.sensor import SensorEntity, SensorStateClass 9 | from homeassistant.const import UnitOfVolume 10 | from homeassistant.core import callback 11 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 12 | 13 | from .const import DOMAIN, LOGGER 14 | from .entity import VeoliaMesurements 15 | 16 | 17 | async def async_setup_entry(hass, entry, async_add_devices) -> None: 18 | """Set up sensor platform.""" 19 | LOGGER.debug("Setting up sensor platform") 20 | coordinator = hass.data[DOMAIN][entry.entry_id] 21 | sensors = [ 22 | LastIndexSensor(coordinator, entry), 23 | DailyConsumption(coordinator, entry), 24 | MonthlyConsumption(coordinator, entry), 25 | AnnualConsumption(coordinator, entry), 26 | LastDateSensor(coordinator, entry), 27 | ] 28 | async_add_devices(sensors) 29 | 30 | 31 | class LastIndexSensor(VeoliaMesurements): 32 | """LastIndexSensor sensor.""" 33 | 34 | @property 35 | def unique_id(self) -> str: 36 | """Return a unique ID to use for this entity.""" 37 | return f"{self.config_entry.entry_id}_last_index" 38 | 39 | @property 40 | def has_entity_name(self) -> bool: 41 | """Indicate that entity has name defined.""" 42 | return True 43 | 44 | @property 45 | def translation_key(self) -> str: 46 | """Translation key for this entity.""" 47 | return "veolia_index" 48 | 49 | @property 50 | def native_value(self) -> float | None: 51 | """Return sensor value.""" 52 | value = self.coordinator.data.computed.last_index_m3 53 | LOGGER.debug("Sensor %s value : %s", self.__class__.__name__, value) 54 | return value 55 | 56 | @property 57 | def extra_state_attributes(self) -> dict: 58 | """Return extra state.""" 59 | comp = self.coordinator.data.computed 60 | return { 61 | "data_type": comp.daily_fiability, 62 | "last_report": comp.last_date.isoformat() if comp.last_date else None, 63 | } 64 | 65 | @property 66 | def state_class(self) -> str: 67 | """Return the state_class of the sensor.""" 68 | return SensorStateClass.TOTAL_INCREASING 69 | 70 | @property 71 | def native_unit_of_measurement(self) -> str: 72 | """Return the unit_of_measurement of the sensor.""" 73 | return UnitOfVolume.CUBIC_METERS 74 | 75 | @property 76 | def suggested_display_precision(self) -> int: 77 | """Return the suggested display precision.""" 78 | return 3 79 | 80 | @property 81 | def icon(self) -> str | None: 82 | """Set icon.""" 83 | return "mdi:counter" 84 | 85 | # NOT WORKING 86 | # async def async_added_to_hass(self) -> None: 87 | # """Start historical update on HA add.""" 88 | # await self._update_historical_data() 89 | # await super().async_added_to_hass() 90 | 91 | @callback 92 | async def _update_historical_data(self) -> None: 93 | """Update historical values.""" 94 | LOGGER.debug("Update_historical_data for %s", self.__class__.__name__) 95 | stats = self.coordinator.data.computed.index_stats_m3 96 | if not stats: 97 | LOGGER.debug("No data update for %s", self.__class__.__name__) 98 | return 99 | metadata = StatisticMetaData( 100 | has_mean=False, 101 | has_sum=True, 102 | mean_type=StatisticMeanType.NONE, 103 | name=None, 104 | source="recorder", 105 | statistic_id=self.entity_id, 106 | unit_of_measurement=UnitOfVolume.CUBIC_METERS, 107 | ) 108 | LOGGER.debug("-> StatisticMetaData %s Data : %s", metadata, stats) 109 | async_import_statistics(self.hass, metadata, stats) 110 | 111 | 112 | class DailyConsumption(VeoliaMesurements): 113 | """DailyConsumption sensor.""" 114 | 115 | @property 116 | def unique_id(self) -> str: 117 | """Return a unique ID to use for this entity.""" 118 | return f"{self.config_entry.entry_id}_daily_consumption" 119 | 120 | @property 121 | def has_entity_name(self) -> bool: 122 | """Indicate that entity has name defined.""" 123 | return True 124 | 125 | @property 126 | def translation_key(self) -> str: 127 | """Translation key for this entity.""" 128 | return "daily_consumption" 129 | 130 | @property 131 | def native_value(self) -> int | None: 132 | """Return sensor value.""" 133 | value = self.coordinator.data.computed.daily_today_liters 134 | LOGGER.debug("Sensor %s value : %s", self.__class__.__name__, value) 135 | return value 136 | 137 | @property 138 | def extra_state_attributes(self) -> dict: 139 | """Return extra state.""" 140 | comp = self.coordinator.data.computed 141 | return { 142 | "data_type": comp.daily_today_fiability, 143 | "last_report": comp.last_date.isoformat() if comp.last_date else None, 144 | } 145 | 146 | async def async_added_to_hass(self) -> None: 147 | """Start historical update on HA add.""" 148 | await self._update_historical_data() 149 | await super().async_added_to_hass() 150 | 151 | @callback 152 | async def _update_historical_data(self) -> None: 153 | """Update historical values.""" 154 | LOGGER.debug("Update_historical_data for %s", self.__class__.__name__) 155 | stats = self.coordinator.data.computed.daily_stats_liters 156 | if not stats: 157 | LOGGER.debug("No data update for %s", self.__class__.__name__) 158 | return 159 | metadata = StatisticMetaData( 160 | has_mean=True, 161 | has_sum=True, 162 | mean_type=StatisticMeanType.ARITHMETIC, 163 | name=None, 164 | source="recorder", 165 | statistic_id=self.entity_id, 166 | unit_of_measurement=UnitOfVolume.LITERS, 167 | ) 168 | LOGGER.debug("-> StatisticMetaData %s Data : %s", metadata, stats) 169 | async_import_statistics(self.hass, metadata, stats) 170 | 171 | @property 172 | def state_class(self) -> str: 173 | """Return the state_class of the sensor.""" 174 | return SensorStateClass.TOTAL 175 | 176 | @property 177 | def native_unit_of_measurement(self) -> str: 178 | """Return the unit_of_measurement of the sensor.""" 179 | return UnitOfVolume.LITERS 180 | 181 | @property 182 | def suggested_display_precision(self) -> int: 183 | """Return the suggested display precision.""" 184 | return 0 185 | 186 | @property 187 | def icon(self) -> str | None: 188 | """Set icon.""" 189 | return "mdi:water" 190 | 191 | 192 | class MonthlyConsumption(VeoliaMesurements): 193 | """MonthlyConsumption sensor.""" 194 | 195 | @property 196 | def unique_id(self) -> str: 197 | """Return a unique ID to use for this entity.""" 198 | return f"{self.config_entry.entry_id}_monthly_consumption" 199 | 200 | @property 201 | def has_entity_name(self) -> bool: 202 | """Indicate that entity has name defined.""" 203 | return True 204 | 205 | @property 206 | def translation_key(self) -> str: 207 | """Translation key for this entity.""" 208 | return "monthly_consumption" 209 | 210 | @property 211 | def native_value(self) -> float | None: 212 | """Return sensor value.""" 213 | value = self.coordinator.data.computed.monthly_latest_m3 214 | LOGGER.debug("Sensor %s value : %s", self.__class__.__name__, value) 215 | return value 216 | 217 | @property 218 | def extra_state_attributes(self) -> dict: 219 | """Return extra state.""" 220 | return {"data_type": self.coordinator.data.computed.monthly_fiability} 221 | 222 | @property 223 | def state_class(self) -> str: 224 | """Return the state_class of the sensor.""" 225 | return SensorStateClass.TOTAL 226 | 227 | @property 228 | def native_unit_of_measurement(self) -> str: 229 | """Return the unit_of_measurement of the sensor.""" 230 | return UnitOfVolume.CUBIC_METERS 231 | 232 | @property 233 | def suggested_display_precision(self) -> int: 234 | """Return the suggested display precision.""" 235 | return 3 236 | 237 | @property 238 | def icon(self) -> str | None: 239 | """Set icon.""" 240 | return "mdi:water" 241 | 242 | async def async_added_to_hass(self) -> None: 243 | """Start historical update on HA add.""" 244 | await self._update_historical_data() 245 | await super().async_added_to_hass() 246 | 247 | @callback 248 | async def _update_historical_data(self) -> None: 249 | """Update historical values.""" 250 | LOGGER.debug("Update_historical_data for %s", self.__class__.__name__) 251 | stats = self.coordinator.data.computed.monthly_stats_cubic_meters 252 | if not stats: 253 | LOGGER.debug("No data update for %s", self.__class__.__name__) 254 | return 255 | metadata = StatisticMetaData( 256 | has_mean=False, 257 | has_sum=True, 258 | mean_type=StatisticMeanType.NONE, 259 | name=None, 260 | source="recorder", 261 | statistic_id=self.entity_id, 262 | unit_of_measurement=UnitOfVolume.CUBIC_METERS, 263 | ) 264 | LOGGER.debug("-> StatisticMetaData %s Data : %s", metadata, stats) 265 | async_import_statistics(self.hass, metadata, stats) 266 | 267 | 268 | class AnnualConsumption(VeoliaMesurements): 269 | """AnnualConsumption sensor.""" 270 | 271 | @property 272 | def unique_id(self) -> str: 273 | """Return a unique ID to use for this entity.""" 274 | return f"{self.config_entry.entry_id}_annual_consumption" 275 | 276 | @property 277 | def has_entity_name(self) -> bool: 278 | """Indicate that entity has name defined.""" 279 | return True 280 | 281 | @property 282 | def translation_key(self) -> str: 283 | """Translation key for this entity.""" 284 | return "annual_consumption" 285 | 286 | @property 287 | def native_value(self) -> float | None: 288 | """Return sensor value.""" 289 | value = self.coordinator.data.computed.annual_total_m3 290 | LOGGER.debug("Sensor %s value : %s", self.__class__.__name__, value) 291 | return value 292 | 293 | @property 294 | def state_class(self) -> str: 295 | """Return the state_class of the sensor.""" 296 | return SensorStateClass.TOTAL 297 | 298 | @property 299 | def native_unit_of_measurement(self) -> str: 300 | """Return the unit_of_measurement of the sensor.""" 301 | return UnitOfVolume.CUBIC_METERS 302 | 303 | @property 304 | def suggested_display_precision(self) -> int: 305 | """Return the suggested display precision.""" 306 | return 3 307 | 308 | @property 309 | def icon(self) -> str | None: 310 | """Set icon.""" 311 | return "mdi:water" 312 | 313 | 314 | class LastDateSensor(CoordinatorEntity, SensorEntity): 315 | """LastDateSensor sensor.""" 316 | 317 | def __init__(self, coordinator, config_entry) -> None: 318 | """Initialize the entity.""" 319 | super().__init__(coordinator) 320 | self.config_entry = config_entry 321 | 322 | @property 323 | def unique_id(self) -> str: 324 | """Return a unique ID to use for this entity.""" 325 | return f"{self.config_entry.entry_id}_last_date" 326 | 327 | @property 328 | def has_entity_name(self) -> bool: 329 | """Indicate that entity has name defined.""" 330 | return True 331 | 332 | @property 333 | def translation_key(self) -> str: 334 | """Translation key for this entity.""" 335 | return "last_consumption_date" 336 | 337 | @property 338 | def native_value(self) -> str | None: 339 | """Return sensor value.""" 340 | value = self.coordinator.data.computed.last_date 341 | LOGGER.debug("Sensor %s value : %s", self.__class__.__name__, value) 342 | return value 343 | 344 | @property 345 | def icon(self) -> str | None: 346 | """Set icon.""" 347 | return "mdi:calendar" 348 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pre-commit-update] 2 | dry_run = false 3 | all_versions = false 4 | verbose = true 5 | preview = false 6 | exclude = [] 7 | keep = [] 8 | 9 | 10 | [tool.ruff] 11 | required-version = ">=0.6.8" 12 | 13 | [tool.ruff.lint] 14 | select = [ 15 | "A001", # Variable {name} is shadowing a Python builtin 16 | "ASYNC210", # Async functions should not call blocking HTTP methods 17 | "ASYNC220", # Async functions should not create subprocesses with blocking methods 18 | "ASYNC221", # Async functions should not run processes with blocking methods 19 | "ASYNC222", # Async functions should not wait on processes with blocking methods 20 | "ASYNC230", # Async functions should not open files with blocking methods like open 21 | "ASYNC251", # Async functions should not call time.sleep 22 | "B002", # Python does not support the unary prefix increment 23 | "B005", # Using .strip() with multi-character strings is misleading 24 | "B007", # Loop control variable {name} not used within loop body 25 | "B014", # Exception handler with duplicate exception 26 | "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. 27 | "B017", # pytest.raises(BaseException) should be considered evil 28 | "B018", # Found useless attribute access. Either assign it to a variable or remove it. 29 | "B023", # Function definition does not bind loop variable {name} 30 | "B026", # Star-arg unpacking after a keyword argument is strongly discouraged 31 | "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? 32 | "B904", # Use raise from to specify exception cause 33 | "B905", # zip() without an explicit strict= parameter 34 | "BLE", 35 | "C", # complexity 36 | "COM818", # Trailing comma on bare tuple prohibited 37 | "D", # docstrings 38 | "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() 39 | "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) 40 | "E", # pycodestyle 41 | "F", # pyflakes/autoflake 42 | "F541", # f-string without any placeholders 43 | "FLY", # flynt 44 | "FURB", # refurb 45 | "G", # flake8-logging-format 46 | "I", # isort 47 | "INP", # flake8-no-pep420 48 | "ISC", # flake8-implicit-str-concat 49 | "ICN001", # import concentions; {name} should be imported as {asname} 50 | "LOG", # flake8-logging 51 | "N804", # First argument of a class method should be named cls 52 | "N805", # First argument of a method should be named self 53 | "N815", # Variable {name} in class scope should not be mixedCase 54 | "PERF", # Perflint 55 | "PGH", # pygrep-hooks 56 | "PIE", # flake8-pie 57 | "PL", # pylint 58 | "PT", # flake8-pytest-style 59 | "PTH", # flake8-pathlib 60 | "PYI", # flake8-pyi 61 | "RET", # flake8-return 62 | "RSE", # flake8-raise 63 | "RUF005", # Consider iterable unpacking instead of concatenation 64 | "RUF006", # Store a reference to the return value of asyncio.create_task 65 | "RUF010", # Use explicit conversion flag 66 | "RUF013", # PEP 484 prohibits implicit Optional 67 | "RUF017", # Avoid quadratic list summation 68 | "RUF018", # Avoid assignment expressions in assert statements 69 | "RUF019", # Unnecessary key check before dictionary access 70 | # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up 71 | "S102", # Use of exec detected 72 | "S103", # bad-file-permissions 73 | "S108", # hardcoded-temp-file 74 | "S306", # suspicious-mktemp-usage 75 | "S307", # suspicious-eval-usage 76 | "S313", # suspicious-xmlc-element-tree-usage 77 | "S314", # suspicious-xml-element-tree-usage 78 | "S315", # suspicious-xml-expat-reader-usage 79 | "S316", # suspicious-xml-expat-builder-usage 80 | "S317", # suspicious-xml-sax-usage 81 | "S318", # suspicious-xml-mini-dom-usage 82 | "S319", # suspicious-xml-pull-dom-usage 83 | "S601", # paramiko-call 84 | "S602", # subprocess-popen-with-shell-equals-true 85 | "S604", # call-with-shell-equals-true 86 | "S608", # hardcoded-sql-expression 87 | "S609", # unix-command-wildcard-injection 88 | "SIM", # flake8-simplify 89 | "SLF", # flake8-self 90 | "SLOT", # flake8-slots 91 | "T100", # Trace found: {name} used 92 | "T20", # flake8-print 93 | "TCH", # flake8-type-checking 94 | "TID", # Tidy imports 95 | "TRY", # tryceratops 96 | "UP", # pyupgrade 97 | "UP031", # Use format specifiers instead of percent format 98 | "UP032", # Use f-string instead of `format` call 99 | "W", # pycodestyle 100 | ] 101 | 102 | ignore = [ 103 | "D202", # No blank lines allowed after function docstring 104 | "D203", # 1 blank line required before class docstring 105 | "D213", # Multi-line docstring summary should start at the second line 106 | "D406", # Section name should end with a newline 107 | "D407", # Section name underlining 108 | "E501", # line too long 109 | "BLE001", # Blind exception 110 | 111 | "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives 112 | "PLR0911", # Too many return statements ({returns} > {max_returns}) 113 | "PLR0912", # Too many branches ({branches} > {max_branches}) 114 | "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) 115 | "PLR0915", # Too many statements ({statements} > {max_statements}) 116 | "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable 117 | "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target 118 | "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception 119 | "PT018", # Assertion should be broken down into multiple parts 120 | "RUF001", # String contains ambiguous unicode character. 121 | "RUF002", # Docstring contains ambiguous unicode character. 122 | "RUF003", # Comment contains ambiguous unicode character. 123 | "RUF015", # Prefer next(...) over single element slice 124 | "SIM102", # Use a single if statement instead of nested if statements 125 | "SIM103", # Return the condition {condition} directly 126 | "SIM108", # Use ternary operator {contents} instead of if-else-block 127 | "SIM115", # Use context handler for opening files 128 | 129 | # Moving imports into type-checking blocks can mess with pytest.patch() 130 | "TC001", # Move application import {} into a type-checking block 131 | "TC002", # Move third-party import {} into a type-checking block 132 | "TC003", # Move standard library import {} into a type-checking block 133 | 134 | "TRY003", # Avoid specifying long messages outside the exception class 135 | "TRY400", # Use `logging.exception` instead of `logging.error` 136 | 137 | # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 138 | "W191", 139 | "E111", 140 | "E114", 141 | "E117", 142 | "D206", 143 | "D300", 144 | "Q", 145 | "COM812", 146 | "COM819", 147 | "ISC001", 148 | 149 | # Disabled because ruff does not understand type of __all__ generated by a function 150 | "PLE0605" 151 | ] 152 | 153 | [tool.ruff.lint.flake8-import-conventions.extend-aliases] 154 | voluptuous = "vol" 155 | "homeassistant.components.air_quality.PLATFORM_SCHEMA" = "AIR_QUALITY_PLATFORM_SCHEMA" 156 | "homeassistant.components.alarm_control_panel.PLATFORM_SCHEMA" = "ALARM_CONTROL_PANEL_PLATFORM_SCHEMA" 157 | "homeassistant.components.binary_sensor.PLATFORM_SCHEMA" = "BINARY_SENSOR_PLATFORM_SCHEMA" 158 | "homeassistant.components.button.PLATFORM_SCHEMA" = "BUTTON_PLATFORM_SCHEMA" 159 | "homeassistant.components.calendar.PLATFORM_SCHEMA" = "CALENDAR_PLATFORM_SCHEMA" 160 | "homeassistant.components.camera.PLATFORM_SCHEMA" = "CAMERA_PLATFORM_SCHEMA" 161 | "homeassistant.components.climate.PLATFORM_SCHEMA" = "CLIMATE_PLATFORM_SCHEMA" 162 | "homeassistant.components.conversation.PLATFORM_SCHEMA" = "CONVERSATION_PLATFORM_SCHEMA" 163 | "homeassistant.components.cover.PLATFORM_SCHEMA" = "COVER_PLATFORM_SCHEMA" 164 | "homeassistant.components.date.PLATFORM_SCHEMA" = "DATE_PLATFORM_SCHEMA" 165 | "homeassistant.components.datetime.PLATFORM_SCHEMA" = "DATETIME_PLATFORM_SCHEMA" 166 | "homeassistant.components.device_tracker.PLATFORM_SCHEMA" = "DEVICE_TRACKER_PLATFORM_SCHEMA" 167 | "homeassistant.components.event.PLATFORM_SCHEMA" = "EVENT_PLATFORM_SCHEMA" 168 | "homeassistant.components.fan.PLATFORM_SCHEMA" = "FAN_PLATFORM_SCHEMA" 169 | "homeassistant.components.geo_location.PLATFORM_SCHEMA" = "GEO_LOCATION_PLATFORM_SCHEMA" 170 | "homeassistant.components.humidifier.PLATFORM_SCHEMA" = "HUMIDIFIER_PLATFORM_SCHEMA" 171 | "homeassistant.components.image.PLATFORM_SCHEMA" = "IMAGE_PLATFORM_SCHEMA" 172 | "homeassistant.components.image_processing.PLATFORM_SCHEMA" = "IMAGE_PROCESSING_PLATFORM_SCHEMA" 173 | "homeassistant.components.lawn_mower.PLATFORM_SCHEMA" = "LAWN_MOWER_PLATFORM_SCHEMA" 174 | "homeassistant.components.light.PLATFORM_SCHEMA" = "LIGHT_PLATFORM_SCHEMA" 175 | "homeassistant.components.lock.PLATFORM_SCHEMA" = "LOCK_PLATFORM_SCHEMA" 176 | "homeassistant.components.media_player.PLATFORM_SCHEMA" = "MEDIA_PLAYER_PLATFORM_SCHEMA" 177 | "homeassistant.components.notify.PLATFORM_SCHEMA" = "NOTIFY_PLATFORM_SCHEMA" 178 | "homeassistant.components.number.PLATFORM_SCHEMA" = "NUMBER_PLATFORM_SCHEMA" 179 | "homeassistant.components.remote.PLATFORM_SCHEMA" = "REMOTE_PLATFORM_SCHEMA" 180 | "homeassistant.components.scene.PLATFORM_SCHEMA" = "SCENE_PLATFORM_SCHEMA" 181 | "homeassistant.components.select.PLATFORM_SCHEMA" = "SELECT_PLATFORM_SCHEMA" 182 | "homeassistant.components.sensor.PLATFORM_SCHEMA" = "SENSOR_PLATFORM_SCHEMA" 183 | "homeassistant.components.siren.PLATFORM_SCHEMA" = "SIREN_PLATFORM_SCHEMA" 184 | "homeassistant.components.stt.PLATFORM_SCHEMA" = "STT_PLATFORM_SCHEMA" 185 | "homeassistant.components.switch.PLATFORM_SCHEMA" = "SWITCH_PLATFORM_SCHEMA" 186 | "homeassistant.components.text.PLATFORM_SCHEMA" = "TEXT_PLATFORM_SCHEMA" 187 | "homeassistant.components.time.PLATFORM_SCHEMA" = "TIME_PLATFORM_SCHEMA" 188 | "homeassistant.components.todo.PLATFORM_SCHEMA" = "TODO_PLATFORM_SCHEMA" 189 | "homeassistant.components.tts.PLATFORM_SCHEMA" = "TTS_PLATFORM_SCHEMA" 190 | "homeassistant.components.vacuum.PLATFORM_SCHEMA" = "VACUUM_PLATFORM_SCHEMA" 191 | "homeassistant.components.valve.PLATFORM_SCHEMA" = "VALVE_PLATFORM_SCHEMA" 192 | "homeassistant.components.update.PLATFORM_SCHEMA" = "UPDATE_PLATFORM_SCHEMA" 193 | "homeassistant.components.wake_word.PLATFORM_SCHEMA" = "WAKE_WORD_PLATFORM_SCHEMA" 194 | "homeassistant.components.water_heater.PLATFORM_SCHEMA" = "WATER_HEATER_PLATFORM_SCHEMA" 195 | "homeassistant.components.weather.PLATFORM_SCHEMA" = "WEATHER_PLATFORM_SCHEMA" 196 | "homeassistant.core.DOMAIN" = "HOMEASSISTANT_DOMAIN" 197 | "homeassistant.helpers.area_registry" = "ar" 198 | "homeassistant.helpers.category_registry" = "cr" 199 | "homeassistant.helpers.config_validation" = "cv" 200 | "homeassistant.helpers.device_registry" = "dr" 201 | "homeassistant.helpers.entity_registry" = "er" 202 | "homeassistant.helpers.floor_registry" = "fr" 203 | "homeassistant.helpers.issue_registry" = "ir" 204 | "homeassistant.helpers.label_registry" = "lr" 205 | "homeassistant.util.dt" = "dt_util" 206 | 207 | [tool.ruff.lint.flake8-pytest-style] 208 | fixture-parentheses = false 209 | mark-parentheses = false 210 | 211 | [tool.ruff.lint.flake8-tidy-imports.banned-api] 212 | "async_timeout".msg = "use asyncio.timeout instead" 213 | "pytz".msg = "use zoneinfo instead" 214 | 215 | [tool.ruff.lint.isort] 216 | force-sort-within-sections = true 217 | known-first-party = [ 218 | "homeassistant", 219 | ] 220 | combine-as-imports = true 221 | split-on-trailing-comma = false 222 | 223 | [tool.ruff.lint.per-file-ignores] 224 | 225 | # Allow for main entry & scripts to write to stdout 226 | "homeassistant/__main__.py" = ["T201"] 227 | "homeassistant/scripts/*" = ["T201"] 228 | "script/*" = ["T20"] 229 | 230 | # Allow relative imports within auth and within components 231 | "homeassistant/auth/*/*" = ["TID252"] 232 | "homeassistant/components/*/*/*" = ["TID252"] 233 | "tests/components/*/*/*" = ["TID252"] 234 | 235 | # Temporary 236 | "homeassistant/**" = ["PTH"] 237 | "tests/**" = ["PTH"] 238 | 239 | [tool.ruff.lint.mccabe] 240 | max-complexity = 25 241 | 242 | [tool.ruff.lint.pydocstyle] 243 | property-decorators = ["propcache.cached_property"] 244 | 245 | [tool.bumpver] 246 | current_version = "1.1.2" 247 | version_pattern = "MAJOR.MINOR.PATCH" 248 | commit_message = "bump version {old_version} -> {new_version}" 249 | tag_message = "{new_version}" 250 | tag_scope = "default" 251 | pre_commit_hook = "" 252 | post_commit_hook = "" 253 | commit = true 254 | tag = true 255 | push = true 256 | 257 | [tool.bumpver.file_patterns] 258 | "pyproject.toml" = [ 259 | 'current_version = "{version}"', 260 | ] 261 | "setup.py" = [ 262 | "{version}", 263 | "{pep440_version}", 264 | ] 265 | "README.md" = [ 266 | "{version}", 267 | "{pep440_version}", 268 | ] 269 | 270 | [tool.tox] 271 | min_version = "4.20" 272 | requires = ["tox>=4.23.2"] 273 | env_list = ["precommit"] 274 | 275 | [tool.tox.env.precommit] 276 | description = "run pre-commit hooks" 277 | deps = ["pre-commit>=4.0.1"] 278 | skip_install = true 279 | commands = [["pre-commit", "run", "--all-files"]] 280 | 281 | [testenv.pre-commit] 282 | deps = ["pre-commit"] 283 | commands = ["pre-commit run --all-files"] 284 | --------------------------------------------------------------------------------