├── .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 | [](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 |
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 | [](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 |
--------------------------------------------------------------------------------