Compare commits

...

89 Commits

Author SHA1 Message Date
Robert Resch
fb3ee34c81 Bump prek to 0.2.28 (#160864) 2026-01-13 18:59:07 +01:00
Daniel Hjelseth Høyer
cb99400128 Add Tibber binary sensors (#160365)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-13 18:56:14 +01:00
divers33
58ef925a07 Refactor MELCloud integration to use DataUpdateCoordinator (#160131)
Co-authored-by: divers33 <divers33@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-13 18:52:37 +01:00
Paul Tarjan
41bbfb8725 Add camera platform support to Hikvision integration (#160252)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-13 18:38:18 +01:00
Manu
ed226e31b1 Remove defusedxml dependency from Namecheap DynamicDNS integration (#160656) 2026-01-13 18:16:50 +01:00
Robert Resch
e900bb9770 Add support for packaging version >= 26 on the version bump script (#160858) 2026-01-13 18:14:46 +01:00
Matthias Alphart
d173d25072 Refactor KNX expose entity class (#160705) 2026-01-13 17:25:46 +01:00
Colin
0959896984 openevse: Use a data update coordinator (#160757)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-13 17:04:56 +01:00
epenet
4a3ae454b8 Improve type hints in pushsafer notify (#160851) 2026-01-13 16:46:01 +01:00
Joost Lekkerkerker
f2cf6b69bf Use extended entity descriptions in openevse (#160611) 2026-01-13 16:44:29 +01:00
epenet
176f847ebb Split Tuya climate wrappers (#160839) 2026-01-13 16:38:40 +01:00
epenet
277419aafb Fix logging in mycroft notify (#160852) 2026-01-13 16:28:17 +01:00
Willem-Jan van Rootselaar
d2b8d165d7 Optimize BSB-Lan integration startup (#160784) 2026-01-13 16:07:33 +01:00
Jamin
bf74e67700 Bump voip-utils to 0.3.5 (#160848) 2026-01-13 16:03:55 +01:00
Chris
5c3b85a37a Add authentication to config flow in openevse (#160521)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-13 16:03:40 +01:00
Manu
8543f3f989 Add config flow to Namecheap DynamicDNS integration (#160841) 2026-01-13 15:46:15 +01:00
Sebastian YEPES
52a8a66a91 Bump qingping-ble to 1.1.0 (#160815) 2026-01-13 15:35:50 +01:00
dependabot[bot]
002a931e70 Bump github/codeql-action from 4.31.9 to 4.31.10 (#160829)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 15:33:27 +01:00
Daniel Hjelseth Høyer
0667bfc81d Remove old migration for Tibber (#160845) 2026-01-13 15:31:28 +01:00
Michael Hansen
329b2c840d Revert back to microVAD (#160821) 2026-01-13 08:09:17 -06:00
Robert Resch
ea7e94bcc1 Replace pre-commit by prek (#160427) 2026-01-13 15:09:02 +01:00
nasWebio
cc30add73a Add climate platform to NASweb integration (#141583)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-01-13 14:55:12 +01:00
Simone Chemelli
21cfb9a0e5 Add guest Wi-Fi QR code for Vodafone Station (#160307)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-13 13:57:59 +01:00
Erik Montnemery
143eadd887 Remove progress_step date entry flow decorator (#160844) 2026-01-13 13:52:57 +01:00
Erik Montnemery
855da1d070 Adjust light condition test (#160831) 2026-01-13 10:58:34 +01:00
AlCalzone
d5be76d7e6 Make integration scaffolding a bit more newbie-friendly (#160837) 2026-01-13 10:39:49 +01:00
Matthias Alphart
5f396332df Update xknx to 3.14.0 (#160813) 2026-01-13 10:22:49 +01:00
Kevin Stillhammer
56e638e170 accept leading zeros in sms_code for fressnapf_tracker (#160834) 2026-01-13 10:18:15 +01:00
Norbert Rittel
52b90c7706 Make light conditions consistent with triggers and actions (#160477) 2026-01-13 09:45:31 +01:00
Erik Montnemery
a6221d16b6 Add helper for creating entity condition tests (#160425) 2026-01-13 08:25:41 +01:00
tronikos
51701cab7c Bump opower to 0.16.2 (#160822) 2026-01-12 19:20:06 -08:00
Raphael Hehl
010e1f2d0d Bump uiprotect to 8.1.1 (#160816) 2026-01-12 23:06:50 +01:00
Jonathan de Jong
66909fc9ca Support HVAC mode in set temperature calls in Mill (#155416)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-12 21:46:20 +01:00
Lukas
90a28c95c8 Bump python-pooldose to 0.8.2 (#160800) 2026-01-12 20:20:33 +01:00
Erik Montnemery
83f2c53e8c Disable pyright type checking in VS Code (#160528) 2026-01-12 20:19:19 +01:00
Ludovic BOUÉ
514b6e243c Rename Matter Eve Thermostat Fixture to eve_thermo_v4 (#160796) 2026-01-12 20:16:13 +01:00
Krisjanis Lejejs
742230c7be Bump hass-nabucasa from 1.8.0 to 1.9.0 (#160788) 2026-01-12 19:50:48 +01:00
Ludovic BOUÉ
acb6b1444e Add fixture for Matter Eve Thermo 20ECD1701 (v5) with detailed attributes (#160795) 2026-01-12 18:52:18 +01:00
Erwin Douna
f358b2231a Add match case in perform action (#160150) 2026-01-12 18:25:51 +01:00
Joakim Sørensen
fd24cffa6b Block untill done while setting up cloud in tests (#160780) 2026-01-12 17:32:06 +01:00
Yuxin Wang
0b5d6ee538 Add TIMESTAMP device classes to corresponding sensors in APCUPSD (#160577) 2026-01-12 17:10:25 +01:00
DeerMaximum
d125bb88d1 Use load_json_object_fixture in tests for NINA (#160690)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-12 17:09:18 +01:00
Ludovic BOUÉ
2ab51f582a Add Matter occupied setback for thermostats (#155439) 2026-01-12 16:47:43 +01:00
epenet
f9b32811b2 Move typed ConfigEntry to coordinator module in point (#160786) 2026-01-12 16:34:38 +01:00
seppwabala
41a423e140 Add support for eds0065 in onewire (#160094)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-12 16:21:00 +01:00
Xiangxuan Qu
f717867657 Pass config_entry explicitly to Point coordinator (#160578) 2026-01-12 15:55:41 +01:00
J. Nick Koston
ab202a03db Handle deleted issue during repair flow translation check (#160698) 2026-01-12 15:52:36 +01:00
Álvaro Fernández Rojas
46a3e5e5b5 Fix Airzone Q-Adapt select entities (#160695)
Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>
2026-01-12 15:48:07 +01:00
Krisjanis Lejejs
0163a4d289 Bump hass-nabucasa from 1.7.0 to 1.8.0 (#160775) 2026-01-12 15:46:49 +01:00
Willem-Jan van Rootselaar
6c1bf31a3c Bump python-bsblan to version 4.1.0 (#160676) 2026-01-12 15:44:03 +01:00
Michael
a434760a80 Complete entity name and icon translations in FRITZ!Box Tools (#160746)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-12 15:43:28 +01:00
Jevgeni Kiski
798990fadc Bump vallox-websocket-api to 6.0.0 (#160742) 2026-01-12 15:30:17 +01:00
Glenn de Haan
b3d9d92e4a Add HDFury diagnostics (#160641) 2026-01-12 15:08:19 +01:00
Lukas
1082a9ca69 Pooldose: Sync with docs update (#160190) 2026-01-12 14:41:46 +01:00
Joost Lekkerkerker
c247f56658 Fix fitbit icon (#160750) 2026-01-12 11:08:59 +01:00
Paul Tarjan
e7f71781f1 Fix Hikvision NVR binary sensors not being detected (#160254)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:04:30 +01:00
Josef Zweck
c4b2c5e621 Fix missing key for brew by weight in lamarzocco (#160722) 2026-01-12 11:03:36 +01:00
Thomas55555
7779609a76 Add more pollutants to Google Air Quality (#160738) 2026-01-12 11:02:18 +01:00
Duco Sebel
7b9a5f897c Bump python-homewizard-energy to 10.0.1 (#160736) 2026-01-12 10:59:55 +01:00
epenet
6eccbfc1cf Fix Requirement parsing in RequirementsManager (#160485) 2026-01-12 10:55:39 +01:00
Artur Pragacz
0da518e951 Fix scrape sensor device name (#160765) 2026-01-12 10:53:25 +01:00
Bram Kragten
e5851b7920 Update frontend to 20260107.1 (#160644) 2026-01-12 10:51:49 +01:00
Artur Pragacz
1b9364e8b5 Assign device_entry earlier in entity platform (#160767) 2026-01-12 10:49:01 +01:00
Carter Green
8460d4f5e2 Yolink diagnostic sensors (#160749) 2026-01-12 10:33:49 +01:00
Artur Pragacz
8fd35cd70d Rename registry imports in entity platform (#160766) 2026-01-12 10:27:03 +01:00
MarkGodwin
88be115699 Bump tplink_omada quality scale to bronze (#160762) 2026-01-12 09:52:46 +01:00
J. Nick Koston
7f4063f91e Bump aiodns to 4.0.0 (#160707) 2026-01-11 07:31:31 -10:00
mattreim
080ba46885 Add model id RODRET wireless dimmer (#160636) 2026-01-11 18:22:19 +01:00
Brett Adams
2cb028ee79 Catch any migration failures in Teslemetry (#160549) 2026-01-11 16:46:30 +01:00
mettolen
72655dbf0b Pump pysaunum to 0.2.0 (#160668) 2026-01-11 16:14:45 +01:00
Erwin Douna
153278221d Bump pytado 0.18.16 (#160724) 2026-01-11 13:24:22 +01:00
Daniel Hjelseth Høyer
4942ce7e86 Better handling of ratelimiting from Tibber (#160599)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-11 11:40:27 +01:00
hanwg
98e918cd8a Improve polling error messages for Telegram bot (#160675) 2026-01-11 06:54:50 +01:00
J. Nick Koston
1efc87bfef Bump easyenergy to 2.2.0 (#160709) 2026-01-10 18:54:50 -10:00
Simon Delberghe
b4360ccbd9 Move condition to prioritize preset mode (eco/comfort...) instead of program name in Overkiz (#160189) 2026-01-10 23:58:19 +01:00
Ernst Klamer
ce234d69a7 Revert bthome-ble back to 3.16.0 to fix missing data (#160694) 2026-01-10 09:47:30 -10:00
Álvaro Fernández Rojas
b2a198e230 Update aioairzone to v1.0.5 (#160688) 2026-01-10 20:43:10 +01:00
Michael Hansen
538009d2df Bump pysilero-vad to 3.2.0 (#160691) 2026-01-10 13:35:46 -06:00
Clifford Roche
99329851a2 Bump greeclimate to 2.1.1 (#160683) 2026-01-10 19:51:04 +01:00
DeerMaximum
f8ec395e96 Use snapshots for binary sensor tests in Nina (#160532) 2026-01-10 17:47:29 +01:00
mettolen
98fe189edf Add recalibrate CO2 button to Airobot (#160679) 2026-01-10 17:37:14 +01:00
Samuel Xiao
7b413e3fd3 Bumb switchbot api to v2.10.0 (#160657) 2026-01-10 13:01:55 +01:00
Paul Tarjan
00ca5473d4 Bump pyhik to 0.4.0 (#160654) 2026-01-10 08:04:29 +01:00
Martin Hjelmare
33c808713e Fix Z-Wave creating notification binary sensor for idle state (#160604) 2026-01-10 02:43:13 +01:00
Sid
c97437fbf3 Add the professionel5e filter series to eheimdigital (#155550) 2026-01-09 21:24:01 +01:00
Jordan Harvey
ad8f14fec1 Bump pynintendoparental to 2.3.2 (#160626)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-01-09 20:09:31 +01:00
karwosts
7df586eff1 Use duration selector for timer service (#160391) 2026-01-09 20:07:32 +01:00
Manu
f6fa95d2f7 Rename Namecheap FreeDNS to Dynamic DNS (#160625) 2026-01-09 19:37:03 +01:00
Tero Paloheimo
23a8300012 Add Ruuvi IAQS to Ruuvi BLE (#160529) 2026-01-09 19:04:30 +01:00
249 changed files with 8833 additions and 2207 deletions

View File

@@ -40,7 +40,8 @@
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
"python.analysis.typeCheckingMode": "basic",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,

View File

@@ -847,8 +847,8 @@ rules:
## Development Commands
### Code Quality & Linting
- **Run all linters on all files**: `pre-commit run --all-files`
- **Run linters on staged files only**: `pre-commit run`
- **Run all linters on all files**: `prek run --all-files`
- **Run linters on staged files only**: `prek run`
- **PyLint on everything** (slow): `pylint homeassistant`
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
- **MyPy type checking (whole project)**: `mypy homeassistant/`

View File

@@ -59,7 +59,6 @@ env:
# 15 is the latest version
# - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
PRE_COMMIT_CACHE: ~/.cache/pre-commit
UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
@@ -83,7 +82,6 @@ jobs:
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
integrations: ${{ steps.integrations.outputs.changes }}
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
pre-commit_cache_key: ${{ steps.generate_pre-commit_cache_key.outputs.key }}
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
requirements: ${{ steps.core.outputs.requirements }}
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
@@ -111,11 +109,6 @@ jobs:
hashFiles('requirements_all.txt') }}-${{
hashFiles('homeassistant/package_constraints.txt') }}-${{
hashFiles('script/gen_requirements_all.py') }}" >> $GITHUB_OUTPUT
- name: Generate partial pre-commit restore key
id: generate_pre-commit_cache_key
run: >-
echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{
hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Generate partial apt restore key
id: generate_apt_cache_key
run: |
@@ -244,8 +237,8 @@ jobs:
echo "skip_coverage: ${skip_coverage}"
echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
pre-commit:
name: Prepare pre-commit base
prek:
name: Run prek checks
runs-on: *runs-on-ubuntu
needs: [info]
if: |
@@ -254,147 +247,23 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- *checkout
- &setup-python-default
name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: &key-pre-commit-venv >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: *actions-cache
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
key: &key-pre-commit-env >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Install pre-commit dependencies
if: steps.cache-precommit.outputs.cache-hit != 'true'
run: |
. venv/bin/activate
pre-commit install-hooks
lint-ruff-format:
name: Check ruff-format
runs-on: *runs-on-ubuntu
needs: &needs-pre-commit
- info
- pre-commit
steps:
- *checkout
- *setup-python-default
- &cache-restore-pre-commit-venv
name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
fail-on-cache-miss: true
key: *key-pre-commit-venv
- &cache-restore-pre-commit-env
name: Restore pre-commit environment from cache
id: cache-precommit
uses: *actions-cache-restore
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
key: *key-pre-commit-env
- name: Run ruff-format
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
lint-ruff:
name: Check ruff
runs-on: *runs-on-ubuntu
needs: *needs-pre-commit
steps:
- *checkout
- *setup-python-default
- *cache-restore-pre-commit-venv
- *cache-restore-pre-commit-env
- name: Run ruff
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff-check --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
lint-other:
name: Check other linters
runs-on: *runs-on-ubuntu
needs: *needs-pre-commit
steps:
- *checkout
- *setup-python-default
- *cache-restore-pre-commit-venv
- *cache-restore-pre-commit-env
- name: Register yamllint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/yamllint.json"
- name: Run yamllint
run: |
. venv/bin/activate
pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure
- name: Register check-json problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-json.json"
- name: Run check-json
run: |
. venv/bin/activate
pre-commit run --hook-stage manual check-json --all-files --show-diff-on-failure
- name: Run prettier (fully)
if: needs.info.outputs.test_full_suite == 'true'
run: |
. venv/bin/activate
pre-commit run --hook-stage manual prettier --all-files --show-diff-on-failure
- name: Run prettier (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
run: |
. venv/bin/activate
shopt -s globstar
pre-commit run --hook-stage manual prettier --show-diff-on-failure --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*}
- name: Register check executables problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
- name: Run executables check
run: |
. venv/bin/activate
pre-commit run --hook-stage manual check-executables-have-shebangs --all-files --show-diff-on-failure
- name: Register codespell problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run codespell
run: |
. venv/bin/activate
pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files
- name: Run prek
uses: j178/prek-action@91fd7d7cf70ae1dee9f4f44e7dfa5d1073fe6623 # v1.0.11
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
RUFF_OUTPUT_FORMAT: github
lint-hadolint:
name: Check ${{ matrix.file }}
@@ -434,7 +303,7 @@ jobs:
- &setup-python-matrix
name: Set up Python ${{ matrix.python-version }}
id: python
uses: *actions-setup-python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -447,7 +316,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: *actions-cache
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: &key-python-venv >-
@@ -562,7 +431,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
uses: *actions-cache-restore
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: *path-apt-cache
fail-on-cache-miss: true
@@ -579,7 +448,13 @@ jobs:
-o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
libturbojpeg
- *checkout
- *setup-python-default
- &setup-python-default
name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: *actions-setup-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- &cache-restore-python-default
name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
@@ -782,9 +657,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- lint-other
- lint-ruff
- lint-ruff-format
- prek
- mypy
steps:
- *cache-restore-apt
@@ -823,9 +696,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- lint-other
- lint-ruff
- lint-ruff-format
- prek
- mypy
- prepare-pytest-full
if: |
@@ -949,9 +820,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- lint-other
- lint-ruff
- lint-ruff-format
- prek
- mypy
if: |
needs.info.outputs.lint_only != 'true'
@@ -1066,9 +935,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- lint-other
- lint-ruff
- lint-ruff-format
- prek
- mypy
if: |
needs.info.outputs.lint_only != 'true'
@@ -1202,9 +1069,7 @@ jobs:
- base
- gen-requirements-all
- hassfest
- lint-other
- lint-ruff
- lint-ruff-format
- prek
- mypy
if: |
needs.info.outputs.lint_only != 'true'

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
category: "/language:python"

View File

@@ -46,7 +46,7 @@ repos:
# Run `python-typing-update` hook manually from time to time
# to update python typing syntax.
# Will require manual work, before submitting changes!
# pre-commit run --hook-stage manual python-typing-update --all-files
# prek run --hook-stage manual python-typing-update --all-files
- id: python-typing-update
stages: [manual]
args:

View File

@@ -7,8 +7,8 @@
"python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment",
// Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "basic",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
},

6
.vscode/tasks.json vendored
View File

@@ -45,7 +45,7 @@
{
"label": "Ruff",
"type": "shell",
"command": "pre-commit run ruff-check --all-files",
"command": "prek run ruff-check --all-files",
"group": {
"kind": "test",
"isDefault": true
@@ -57,9 +57,9 @@
"problemMatcher": []
},
{
"label": "Pre-commit",
"label": "Prek",
"type": "shell",
"command": "pre-commit run --show-diff-on-failure",
"command": "prek run --show-diff-on-failure",
"group": {
"kind": "test",
"isDefault": true

2
CODEOWNERS generated
View File

@@ -1068,6 +1068,8 @@ build.json @home-assistant/supervisor
/tests/components/myuplink/ @pajzo @astrandb
/homeassistant/components/nam/ @bieniu
/tests/components/nam/ @bieniu
/homeassistant/components/namecheapdns/ @tr4nt0r
/tests/components/namecheapdns/ @tr4nt0r
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio

View File

@@ -43,6 +43,13 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
),
AirobotButtonEntityDescription(
key="recalibrate_co2",
translation_key="recalibrate_co2",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
press_fn=lambda coordinator: coordinator.client.recalibrate_co2_sensor(),
),
)

View File

@@ -1,5 +1,10 @@
{
"entity": {
"button": {
"recalibrate_co2": {
"default": "mdi:molecule-co2"
}
},
"number": {
"hysteresis_band": {
"default": "mdi:delta"

View File

@@ -59,6 +59,11 @@
}
},
"entity": {
"button": {
"recalibrate_co2": {
"name": "Recalibrate CO2 sensor"
}
},
"number": {
"hysteresis_band": {
"name": "Hysteresis band"

View File

@@ -85,6 +85,22 @@ class AirzoneSystemEntity(AirzoneEntity):
value = system[key]
return value
async def _async_update_sys_params(self, params: dict[str, Any]) -> None:
"""Send system parameters to API."""
_params = {
API_SYSTEM_ID: self.system_id,
**params,
}
_LOGGER.debug("update_sys_params=%s", _params)
try:
await self.coordinator.airzone.set_sys_parameters(_params)
except AirzoneError as error:
raise HomeAssistantError(
f"Failed to set system {self.entity_id}: {error}"
) from error
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())
class AirzoneHotWaterEntity(AirzoneEntity):
"""Define an Airzone Hot Water entity."""

View File

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==1.0.4"]
"requirements": ["aioairzone==1.0.5"]
}

View File

@@ -20,6 +20,7 @@ from aioairzone.const import (
AZD_MODES,
AZD_Q_ADAPT,
AZD_SLEEP,
AZD_SYSTEMS,
AZD_ZONES,
)
@@ -30,7 +31,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneZoneEntity
from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity
@dataclass(frozen=True, kw_only=True)
@@ -85,14 +86,7 @@ def main_zone_options(
return [k for k, v in options.items() if v in modes]
MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_MODE,
key=AZD_MODE,
options_dict=MODE_DICT,
options_fn=main_zone_options,
translation_key="modes",
),
SYSTEM_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_Q_ADAPT,
entity_category=EntityCategory.CONFIG,
@@ -104,6 +98,17 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
)
MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_MODE,
key=AZD_MODE,
options_dict=MODE_DICT,
options_fn=main_zone_options,
translation_key="modes",
),
)
ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_COLD_ANGLE,
@@ -140,16 +145,37 @@ async def async_setup_entry(
"""Add Airzone select from a config_entry."""
coordinator = entry.runtime_data
added_systems: set[str] = set()
added_zones: set[str] = set()
def _async_entity_listener() -> None:
"""Handle additions of select."""
entities: list[AirzoneBaseSelect] = []
systems_data = coordinator.data.get(AZD_SYSTEMS, {})
received_systems = set(systems_data)
new_systems = received_systems - added_systems
if new_systems:
entities.extend(
AirzoneSystemSelect(
coordinator,
description,
entry,
system_id,
systems_data.get(system_id),
)
for system_id in new_systems
for description in SYSTEM_SELECT_TYPES
if description.key in systems_data.get(system_id)
)
added_systems.update(new_systems)
zones_data = coordinator.data.get(AZD_ZONES, {})
received_zones = set(zones_data)
new_zones = received_zones - added_zones
if new_zones:
entities: list[AirzoneZoneSelect] = [
entities.extend(
AirzoneZoneSelect(
coordinator,
description,
@@ -161,8 +187,8 @@ async def async_setup_entry(
for description in MAIN_ZONE_SELECT_TYPES
if description.key in zones_data.get(system_zone_id)
and zones_data.get(system_zone_id).get(AZD_MASTER) is True
]
entities += [
)
entities.extend(
AirzoneZoneSelect(
coordinator,
description,
@@ -173,10 +199,11 @@ async def async_setup_entry(
for system_zone_id in new_zones
for description in ZONE_SELECT_TYPES
if description.key in zones_data.get(system_zone_id)
]
async_add_entities(entities)
)
added_zones.update(new_zones)
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
@@ -203,6 +230,38 @@ class AirzoneBaseSelect(AirzoneEntity, SelectEntity):
self._attr_current_option = self._get_current_option()
class AirzoneSystemSelect(AirzoneSystemEntity, AirzoneBaseSelect):
"""Define an Airzone System select."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
description: AirzoneSelectDescription,
entry: ConfigEntry,
system_id: str,
system_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator, entry, system_data)
self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}"
self.entity_description = description
self._attr_options = self.entity_description.options_fn(
system_data, description.options_dict
)
self.values_dict = {v: k for k, v in description.options_dict.items()}
self._async_update_attrs()
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
param = self.entity_description.api_param
value = self.entity_description.options_dict[option]
await self._async_update_sys_params({param: value})
class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect):
"""Define an Airzone Zone select."""

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
import dateutil
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
@@ -179,6 +181,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
LAST_S_TEST: SensorEntityDescription(
key=LAST_S_TEST,
translation_key="last_self_test",
device_class=SensorDeviceClass.TIMESTAMP,
),
"lastxfer": SensorEntityDescription(
key="lastxfer",
@@ -232,6 +235,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"masterupd": SensorEntityDescription(
key="masterupd",
translation_key="master_update",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"maxlinev": SensorEntityDescription(
@@ -365,6 +369,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
"starttime": SensorEntityDescription(
key="starttime",
translation_key="startup_time",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"statflag": SensorEntityDescription(
@@ -416,16 +421,19 @@ SENSORS: dict[str, SensorEntityDescription] = {
"xoffbat": SensorEntityDescription(
key="xoffbat",
translation_key="transfer_from_battery",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xoffbatt": SensorEntityDescription(
key="xoffbatt",
translation_key="transfer_from_battery",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xonbatt": SensorEntityDescription(
key="xonbatt",
translation_key="transfer_to_battery",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
}
@@ -529,7 +537,13 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
self._attr_native_value = None
return
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
data = self.coordinator.data[key]
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
self._attr_native_value = dateutil.parser.parse(data)
return
self._attr_native_value, inferred_unit = infer_unit(data)
if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit

View File

@@ -3,9 +3,8 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
import logging
import math
from pysilero_vad import SileroVoiceActivityDetector
from pymicro_vad import MicroVad
from pyspeex_noise import AudioProcessor
from .const import BYTES_PER_CHUNK
@@ -43,8 +42,8 @@ class AudioEnhancer(ABC):
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
class SileroVadSpeexEnhancer(AudioEnhancer):
"""Audio enhancer that runs Silero VAD and speex."""
class MicroVadSpeexEnhancer(AudioEnhancer):
"""Audio enhancer that runs microVAD and speex."""
def __init__(
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
@@ -70,49 +69,21 @@ class SileroVadSpeexEnhancer(AudioEnhancer):
self.noise_suppression,
)
self.vad: SileroVoiceActivityDetector | None = None
# We get 10ms chunks but Silero works on 32ms chunks, so we have to
# buffer audio. The previous speech probability is used until enough
# audio has been buffered.
self._vad_buffer: bytearray | None = None
self._vad_buffer_chunks = 0
self._vad_buffer_chunk_idx = 0
self._last_speech_probability: float | None = None
self.vad: MicroVad | None = None
if self.is_vad_enabled:
self.vad = SileroVoiceActivityDetector()
# VAD buffer is a multiple of 10ms, but Silero VAD needs 32ms.
self._vad_buffer_chunks = int(
math.ceil(self.vad.chunk_bytes() / BYTES_PER_CHUNK)
)
self._vad_leftover_bytes = self.vad.chunk_bytes() - BYTES_PER_CHUNK
self._vad_buffer = bytearray(self.vad.chunk_bytes())
_LOGGER.debug("Initialized Silero VAD")
self.vad = MicroVad()
_LOGGER.debug("Initialized microVAD")
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
speech_probability: float | None = None
assert len(audio) == BYTES_PER_CHUNK
if self.vad is not None:
# Run VAD
assert self._vad_buffer is not None
start_idx = self._vad_buffer_chunk_idx * BYTES_PER_CHUNK
self._vad_buffer[start_idx : start_idx + BYTES_PER_CHUNK] = audio
self._vad_buffer_chunk_idx += 1
if self._vad_buffer_chunk_idx >= self._vad_buffer_chunks:
# We have enough data to run Silero VAD (32 ms)
self._last_speech_probability = self.vad.process_chunk(
self._vad_buffer[: self.vad.chunk_bytes()]
)
# Copy leftover audio that wasn't processed to start
self._vad_buffer[: self._vad_leftover_bytes] = self._vad_buffer[
-self._vad_leftover_bytes :
]
self._vad_buffer_chunk_idx = 0
speech_probability = self.vad.Process10ms(audio)
if self.audio_processor is not None:
# Run noise suppression and auto gain
@@ -121,5 +92,5 @@ class SileroVadSpeexEnhancer(AudioEnhancer):
return EnhancedAudioChunk(
audio=audio,
timestamp_ms=timestamp_ms,
speech_probability=self._last_speech_probability,
speech_probability=speech_probability,
)

View File

@@ -8,5 +8,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["pysilero-vad==3.1.0", "pyspeex-noise==1.0.2"]
"requirements": ["pymicro-vad==1.0.1", "pyspeex-noise==1.0.2"]
}

View File

@@ -55,7 +55,7 @@ from homeassistant.util import (
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.limited_size_dict import LimitedSizeDict
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, SileroVadSpeexEnhancer
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
from .const import (
ACKNOWLEDGE_PATH,
BYTES_PER_CHUNK,
@@ -633,7 +633,7 @@ class PipelineRun:
# Initialize with audio settings
if self.audio_settings.needs_processor and (self.audio_enhancer is None):
# Default audio enhancer
self.audio_enhancer = SileroVadSpeexEnhancer(
self.audio_enhancer = MicroVadSpeexEnhancer(
self.audio_settings.auto_gain_dbfs,
self.audio_settings.noise_suppression_level,
self.audio_settings.is_vad_enabled,

View File

@@ -1,5 +1,6 @@
"""The BSB-Lan integration."""
import asyncio
import dataclasses
from bsblan import (
@@ -77,12 +78,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
bsblan = BSBLAN(config, session)
try:
# Initialize the client first - this sets up internal caches and validates the connection
# Initialize the client first - this sets up internal caches and validates
# the connection by fetching firmware version
await bsblan.initialize()
# Fetch all required device metadata
device = await bsblan.device()
info = await bsblan.info()
static = await bsblan.static_values()
# Fetch device metadata in parallel for faster startup
device, info, static = await asyncio.gather(
bsblan.device(),
bsblan.info(),
bsblan.static_values(),
)
except BSBLANConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
@@ -110,10 +115,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
# Perform first refresh of both coordinators
# Perform first refresh of fast coordinator (required for entities)
await fast_coordinator.async_config_entry_first_refresh()
# Try to refresh slow coordinator, but don't fail if DHW is not available
# Refresh slow coordinator - don't fail if DHW is not available
# This allows the integration to work even if the device doesn't support DHW
await slow_coordinator.async_refresh()

View File

@@ -111,11 +111,17 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
return None
return self.coordinator.data.state.target_temperature.value
@property
def _hvac_mode_value(self) -> int | str | None:
"""Return the raw hvac_mode value from the coordinator."""
if (hvac_mode := self.coordinator.data.state.hvac_mode) is None:
return None
return hvac_mode.value
@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac operation ie. heat, cool mode."""
hvac_mode_value = self.coordinator.data.state.hvac_mode.value
if hvac_mode_value is None:
if (hvac_mode_value := self._hvac_mode_value) is None:
return None
# BSB-Lan returns integer values: 0=off, 1=auto, 2=eco, 3=heat
if isinstance(hvac_mode_value, int):
@@ -125,9 +131,8 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
hvac_mode_value = self.coordinator.data.state.hvac_mode.value
# BSB-Lan mode 2 is eco/reduced mode
if hvac_mode_value == 2:
if self._hvac_mode_value == 2:
return PRESET_ECO
return PRESET_NONE

View File

@@ -2,7 +2,6 @@
from dataclasses import dataclass
from datetime import timedelta
from random import randint
from bsblan import (
BSBLAN,
@@ -23,6 +22,17 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER, SCAN_INTERVAL_FAST, SCAN_INTERVAL_SLOW
# Filter lists for optimized API calls - only fetch parameters we actually use
# This significantly reduces response time (~0.2s per parameter saved)
STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
SENSOR_INCLUDE = ["current_temperature", "outside_temperature"]
DHW_STATE_INCLUDE = [
"operating_mode",
"nominal_setpoint",
"dhw_actual_value_top_temperature",
]
DHW_CONFIG_INCLUDE = ["reduced_setpoint", "nominal_setpoint_max"]
@dataclass
class BSBLanFastData:
@@ -80,26 +90,18 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
config_entry,
client,
name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}",
update_interval=self._get_update_interval(),
update_interval=SCAN_INTERVAL_FAST,
)
def _get_update_interval(self) -> timedelta:
"""Get the update interval with a random offset.
Add a random number of seconds to avoid timeouts when
the BSB-Lan device is already/still busy retrieving data,
e.g. for MQTT or internal logging.
"""
return SCAN_INTERVAL_FAST + timedelta(seconds=randint(1, 8))
async def _async_update_data(self) -> BSBLanFastData:
"""Fetch fast-changing data from the BSB-Lan device."""
try:
# Client is already initialized in async_setup_entry
# Fetch fast-changing data (state, sensor, DHW state)
state = await self.client.state()
sensor = await self.client.sensor()
dhw = await self.client.hot_water_state()
# Use include filtering to only fetch parameters we actually use
# This reduces response time significantly (~0.2s per parameter)
state = await self.client.state(include=STATE_INCLUDE)
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
@@ -111,9 +113,6 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
f"Error while establishing connection with BSB-Lan device at {host}"
) from err
# Update the interval with random jitter for next update
self.update_interval = self._get_update_interval()
return BSBLanFastData(
state=state,
sensor=sensor,
@@ -143,8 +142,8 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
"""Fetch slow-changing data from the BSB-Lan device."""
try:
# Client is already initialized in async_setup_entry
# Fetch slow-changing configuration data
dhw_config = await self.client.hot_water_config()
# Use include filtering to only fetch parameters we actually use
dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE)
dhw_schedule = await self.client.hot_water_schedule()
except AttributeError:

View File

@@ -29,7 +29,11 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
connections={(CONNECTION_NETWORK_MAC, format_mac(mac))},
name=data.device.name,
manufacturer="BSBLAN Inc.",
model=data.info.device_identification.value,
model=(
data.info.device_identification.value
if data.info.device_identification
else None
),
sw_version=data.device.version,
configuration_url=f"http://{host}",
)

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
"requirements": ["python-bsblan==3.1.6"],
"requirements": ["python-bsblan==4.1.0"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.17.0"]
"requirements": ["bthome-ble==3.16.0"]
}

View File

@@ -50,7 +50,6 @@ from . import (
from .client import CloudClient
from .const import (
CONF_ACCOUNT_LINK_SERVER,
CONF_ACCOUNTS_SERVER,
CONF_ACME_SERVER,
CONF_ALEXA,
CONF_ALIASES,
@@ -138,7 +137,6 @@ _BASE_CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
vol.Optional(CONF_ACCOUNTS_SERVER): str,
vol.Optional(CONF_ACME_SERVER): str,
vol.Optional(CONF_API_SERVER): str,
vol.Optional(CONF_RELAYER_SERVER): str,

View File

@@ -76,7 +76,6 @@ CONF_GOOGLE_ACTIONS = "google_actions"
CONF_USER_POOL_ID = "user_pool_id"
CONF_ACCOUNT_LINK_SERVER = "account_link_server"
CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server"
CONF_API_SERVER = "api_server"
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.7.0"],
"requirements": ["hass-nabucasa==1.9.0"],
"single_config_entry": true
}

View File

@@ -169,6 +169,7 @@ FRIENDS_OF_HUE_SWITCH = {
}
RODRET_REMOTE_MODEL = "RODRET Dimmer"
RODRET_REMOTE_MODEL_2 = "RODRET wireless dimmer"
RODRET_REMOTE = {
(CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002},
(CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001},
@@ -624,6 +625,7 @@ REMOTES = {
HUE_WALL_REMOTE_MODEL: HUE_WALL_REMOTE,
FRIENDS_OF_HUE_SWITCH_MODEL: FRIENDS_OF_HUE_SWITCH,
RODRET_REMOTE_MODEL: RODRET_REMOTE,
RODRET_REMOTE_MODEL_2: RODRET_REMOTE,
SOMRIG_REMOTE_MODEL: SOMRIG_REMOTE,
STYRBAR_REMOTE_MODEL: STYRBAR_REMOTE,
SYMFONISK_SOUND_CONTROLLER_MODEL: SYMFONISK_SOUND_CONTROLLER,

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/dnsip",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["aiodns==3.6.1"]
"requirements": ["aiodns==4.0.0"]
}

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["easyenergy==2.1.2"],
"requirements": ["easyenergy==2.2.0"],
"single_config_entry": true
}

View File

@@ -37,7 +37,7 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
name=device.name,
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
manufacturer="EHEIM",
model=device.device_type.model_name,
model=device.model_name,
identifiers={(DOMAIN, device.mac_address)},
suggested_area=device.aquarium_name,
sw_version=device.sw_version,
@@ -59,9 +59,9 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
def exception_handler[_EntityT: EheimDigitalEntity[EheimDigitalDevice], **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate AirGradient calls to handle exceptions.
"""Decorate eheimdigital calls to handle exceptions.
A decorator that wraps the passed in function, catches AirGradient errors.
A decorator that wraps the passed in function, catches eheimdigital errors.
"""
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:

View File

@@ -6,6 +6,7 @@ from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.types import HeaterUnit
@@ -21,6 +22,7 @@ from homeassistant.const import (
PRECISION_WHOLE,
EntityCategory,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -42,6 +44,34 @@ class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
uom_fn: Callable[[_DeviceT], str] | None = None
FILTER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalFilter], ...] = (
EheimDigitalNumberDescription[EheimDigitalFilter](
key="high_pulse_time",
translation_key="high_pulse_time",
entity_category=EntityCategory.CONFIG,
native_step=PRECISION_WHOLE,
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=NumberDeviceClass.DURATION,
native_min_value=5,
native_max_value=200000,
value_fn=lambda device: device.high_pulse_time,
set_value_fn=lambda device, value: device.set_high_pulse_time(int(value)),
),
EheimDigitalNumberDescription[EheimDigitalFilter](
key="low_pulse_time",
translation_key="low_pulse_time",
entity_category=EntityCategory.CONFIG,
native_step=PRECISION_WHOLE,
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=NumberDeviceClass.DURATION,
native_min_value=5,
native_max_value=200000,
value_fn=lambda device: device.low_pulse_time,
set_value_fn=lambda device, value: device.set_low_pulse_time(int(value)),
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalNumberDescription[EheimDigitalClassicVario], ...
] = (
@@ -145,6 +175,13 @@ async def async_setup_entry(
)
for description in CLASSICVARIO_DESCRIPTIONS
)
if isinstance(device, EheimDigitalFilter):
entities.extend(
EheimDigitalNumber[EheimDigitalFilter](
coordinator, device, description
)
for description in FILTER_DESCRIPTIONS
)
if isinstance(device, EheimDigitalHeater):
entities.extend(
EheimDigitalNumber[EheimDigitalHeater](

View File

@@ -2,13 +2,19 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, override
from typing import Any, Literal, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import FilterMode
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.types import (
FilterMode,
FilterModeProf,
UnitOfMeasurement as EheimDigitalUnitOfMeasurement,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory, UnitOfFrequency, UnitOfVolumeFlowRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -24,8 +30,109 @@ class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
):
"""Class describing EHEIM Digital select entities."""
options_fn: Callable[[_DeviceT], list[str]] | None = None
use_api_unit: Literal[True] | None = None
value_fn: Callable[[_DeviceT], str | None]
set_value_fn: Callable[[_DeviceT, str], Awaitable[None]]
set_value_fn: Callable[[_DeviceT, str], Awaitable[None] | None]
FILTER_DESCRIPTIONS: tuple[EheimDigitalSelectDescription[EheimDigitalFilter], ...] = (
EheimDigitalSelectDescription[EheimDigitalFilter](
key="filter_mode",
translation_key="filter_mode",
entity_category=EntityCategory.CONFIG,
options=[item.lower() for item in FilterModeProf._member_names_],
value_fn=lambda device: device.filter_mode.name.lower(),
set_value_fn=lambda device, value: device.set_filter_mode(
FilterModeProf[value.upper()]
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="manual_speed",
translation_key="manual_speed",
entity_category=EntityCategory.CONFIG,
unit_of_measurement=UnitOfFrequency.HERTZ,
options_fn=lambda device: [str(i) for i in device.filter_manual_values],
value_fn=lambda device: str(device.manual_speed),
set_value_fn=lambda device, value: device.set_manual_speed(float(value)),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="const_flow_speed",
translation_key="const_flow_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(device.filter_const_flow_values[device.const_flow]),
set_value_fn=(
lambda device, value: device.set_const_flow(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="day_speed",
translation_key="day_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(device.filter_const_flow_values[device.day_speed]),
set_value_fn=(
lambda device, value: device.set_day_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="night_speed",
translation_key="night_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(
device.filter_const_flow_values[device.night_speed]
),
set_value_fn=(
lambda device, value: device.set_night_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="high_pulse_speed",
translation_key="high_pulse_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(
device.filter_const_flow_values[device.high_pulse_speed]
),
set_value_fn=(
lambda device, value: device.set_high_pulse_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="low_pulse_speed",
translation_key="low_pulse_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(
device.filter_const_flow_values[device.low_pulse_speed]
),
set_value_fn=(
lambda device, value: device.set_low_pulse_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
@@ -34,11 +141,7 @@ CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSelectDescription[EheimDigitalClassicVario](
key="filter_mode",
translation_key="filter_mode",
value_fn=(
lambda device: device.filter_mode.name.lower()
if device.filter_mode is not None
else None
),
value_fn=lambda device: device.filter_mode.name.lower(),
set_value_fn=(
lambda device, value: device.set_filter_mode(FilterMode[value.upper()])
),
@@ -68,6 +171,11 @@ async def async_setup_entry(
)
for description in CLASSICVARIO_DESCRIPTIONS
)
if isinstance(device, EheimDigitalFilter):
entities.extend(
EheimDigitalFilterSelect(coordinator, device, description)
for description in FILTER_DESCRIPTIONS
)
async_add_entities(entities)
@@ -82,6 +190,8 @@ class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
entity_description: EheimDigitalSelectDescription[_DeviceT]
_attr_options: list[str]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
@@ -91,13 +201,49 @@ class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
"""Initialize an EHEIM Digital select entity."""
super().__init__(coordinator, device)
self.entity_description = description
if description.options_fn is not None:
self._attr_options = description.options_fn(device)
elif description.options is not None:
self._attr_options = description.options
self._attr_unique_id = f"{self._device_address}_{description.key}"
@override
@exception_handler
async def async_select_option(self, option: str) -> None:
return await self.entity_description.set_value_fn(self._device, option)
if await_return := self.entity_description.set_value_fn(self._device, option):
return await await_return
return None
@override
def _async_update_attrs(self) -> None:
self._attr_current_option = self.entity_description.value_fn(self._device)
class EheimDigitalFilterSelect(EheimDigitalSelect[EheimDigitalFilter]):
"""Represent an EHEIM Digital Filter select entity."""
entity_description: EheimDigitalSelectDescription[EheimDigitalFilter]
_attr_native_unit_of_measurement: str | None
@override
def _async_update_attrs(self) -> None:
if (
self.entity_description.options is None
and self.entity_description.options_fn is not None
):
self._attr_options = self.entity_description.options_fn(self._device)
if self.entity_description.use_api_unit:
if (
self.entity_description.unit_of_measurement
== UnitOfVolumeFlowRate.LITERS_PER_HOUR
and self._device.usrdta["unit"]
== int(EheimDigitalUnitOfMeasurement.US_CUSTOMARY)
):
self._attr_native_unit_of_measurement = (
UnitOfVolumeFlowRate.GALLONS_PER_HOUR
)
else:
self._attr_native_unit_of_measurement = (
self.entity_description.unit_of_measurement
)
super()._async_update_attrs()

View File

@@ -6,6 +6,7 @@ from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.types import FilterErrorCode
from homeassistant.components.sensor import (
@@ -13,7 +14,7 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfFrequency, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -33,6 +34,27 @@ class EheimDigitalSensorDescription[_DeviceT: EheimDigitalDevice](
value_fn: Callable[[_DeviceT], float | str | None]
FILTER_DESCRIPTIONS: tuple[EheimDigitalSensorDescription[EheimDigitalFilter], ...] = (
EheimDigitalSensorDescription[EheimDigitalFilter](
key="current_speed",
translation_key="current_speed",
value_fn=lambda device: device.current_speed,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
),
EheimDigitalSensorDescription[EheimDigitalFilter](
key="service_hours",
translation_key="service_hours",
value_fn=lambda device: device.service_hours,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.HOURS,
suggested_unit_of_measurement=UnitOfTime.DAYS,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSensorDescription[EheimDigitalClassicVario], ...
] = (
@@ -54,11 +76,7 @@ CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="error_code",
translation_key="error_code",
value_fn=(
lambda device: device.error_code.name.lower()
if device.error_code is not None
else None
),
value_fn=lambda device: device.error_code.name.lower(),
device_class=SensorDeviceClass.ENUM,
options=[name.lower() for name in FilterErrorCode._member_names_],
entity_category=EntityCategory.DIAGNOSTIC,
@@ -80,6 +98,13 @@ async def async_setup_entry(
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalSensor[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalFilter):
entities += [
EheimDigitalSensor[EheimDigitalFilter](
coordinator, device, description
)
for description in FILTER_DESCRIPTIONS
]
if isinstance(device, EheimDigitalClassicVario):
entities += [
EheimDigitalSensor[EheimDigitalClassicVario](

View File

@@ -61,6 +61,12 @@
"day_speed": {
"name": "Day speed"
},
"high_pulse_time": {
"name": "High pulse duration"
},
"low_pulse_time": {
"name": "Low pulse duration"
},
"manual_speed": {
"name": "Manual speed"
},
@@ -78,13 +84,32 @@
}
},
"select": {
"const_flow_speed": {
"name": "Constant flow speed"
},
"day_speed": {
"name": "Day speed"
},
"filter_mode": {
"name": "Filter mode",
"state": {
"bio": "Bio",
"constant_flow": "Constant flow",
"manual": "Manual",
"pulse": "Pulse"
}
},
"high_pulse_speed": {
"name": "High pulse speed"
},
"low_pulse_speed": {
"name": "Low pulse speed"
},
"manual_speed": {
"name": "Manual speed"
},
"night_speed": {
"name": "Night speed"
}
},
"sensor": {
@@ -99,8 +124,17 @@
"rotor_stuck": "Rotor stuck"
}
},
"operating_time": {
"name": "Operating time"
},
"service_hours": {
"name": "Remaining hours until service"
},
"turn_feeding_time": {
"name": "Remaining off time after feeding"
},
"turn_off_time": {
"name": "Remaining off time"
}
},
"time": {

View File

@@ -4,6 +4,7 @@ from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
@@ -30,8 +31,8 @@ async def async_setup_entry(
"""Set up the switch entities for one or multiple devices."""
entities: list[SwitchEntity] = []
for device in device_address.values():
if isinstance(device, EheimDigitalClassicVario):
entities.append(EheimDigitalClassicVarioSwitch(coordinator, device)) # noqa: PERF401
if isinstance(device, (EheimDigitalClassicVario, EheimDigitalFilter)):
entities.append(EheimDigitalFilterSwitch(coordinator, device)) # noqa: PERF401
async_add_entities(entities)
@@ -39,10 +40,10 @@ async def async_setup_entry(
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalClassicVarioSwitch(
EheimDigitalEntity[EheimDigitalClassicVario], SwitchEntity
class EheimDigitalFilterSwitch(
EheimDigitalEntity[EheimDigitalClassicVario | EheimDigitalFilter], SwitchEntity
):
"""Represent an EHEIM Digital classicVARIO switch entity."""
"""Represent an EHEIM Digital classicVARIO or filter switch entity."""
_attr_translation_key = "filter_active"
_attr_name = None
@@ -50,9 +51,9 @@ class EheimDigitalClassicVarioSwitch(
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: EheimDigitalClassicVario,
device: EheimDigitalClassicVario | EheimDigitalFilter,
) -> None:
"""Initialize an EHEIM Digital classicVARIO switch entity."""
"""Initialize an EHEIM Digital classicVARIO or filter switch entity."""
super().__init__(coordinator, device)
self._attr_unique_id = device.mac_address
self._async_update_attrs()

View File

@@ -7,6 +7,7 @@ from typing import Any, final, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.heater import EheimDigitalHeater
from homeassistant.components.time import TimeEntity, TimeEntityDescription
@@ -28,6 +29,23 @@ class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescri
set_value_fn: Callable[[_DeviceT, time], Awaitable[None]]
FILTER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalFilter], ...] = (
EheimDigitalTimeDescription[EheimDigitalFilter](
key="day_start_time",
translation_key="day_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: device.day_start_time,
set_value_fn=lambda device, value: device.set_day_start_time(value),
),
EheimDigitalTimeDescription[EheimDigitalFilter](
key="night_start_time",
translation_key="night_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: device.night_start_time,
set_value_fn=lambda device, value: device.set_night_start_time(value),
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalTimeDescription[EheimDigitalClassicVario], ...
] = (
@@ -79,6 +97,13 @@ async def async_setup_entry(
"""Set up the time entities for one or multiple devices."""
entities: list[EheimDigitalTime[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalFilter):
entities.extend(
EheimDigitalTime[EheimDigitalFilter](
coordinator, device, description
)
for description in FILTER_DESCRIPTIONS
)
if isinstance(device, EheimDigitalClassicVario):
entities.extend(
EheimDigitalTime[EheimDigitalClassicVario](

View File

@@ -461,7 +461,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = (
key="sleep/timeInBed",
translation_key="sleep_time_in_bed",
native_unit_of_measurement=UnitOfTime.MINUTES,
icon="mdi:hotel",
icon="mdi:bed",
device_class=SensorDeviceClass.DURATION,
scope=FitbitScope.SLEEP,
state_class=SensorStateClass.TOTAL_INCREASING,

View File

@@ -31,7 +31,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
)
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_SMS_CODE): int,
vol.Required(CONF_SMS_CODE): str,
}
)
@@ -75,7 +75,7 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
return errors, False
async def _async_verify_sms_code(
self, sms_code: int
self, sms_code: str
) -> tuple[dict[str, str], str | None]:
"""Verify SMS code and return errors and access_token."""
errors: dict[str, str] = {}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["fressnapftracker==0.2.0"]
"requirements": ["fressnapftracker==0.2.1"]
}

View File

@@ -164,13 +164,12 @@ def _async_wol_buttons_list(
class FritzBoxWOLButton(FritzDeviceBase, ButtonEntity):
"""Defines a FRITZ!Box Tools Wake On LAN button."""
_attr_icon = "mdi:lan-pending"
_attr_entity_registry_enabled_default = False
_attr_translation_key = "wake_on_lan"
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Initialize Fritz!Box WOL button."""
super().__init__(avm_wrapper, device)
self._name = f"{self.hostname} Wake on LAN"
self._attr_unique_id = f"{self._mac}_wake_on_lan"
self._is_available = True

View File

@@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEFAULT_DEVICE_NAME
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
from .entity import FritzDeviceBase
from .helpers import device_filter_out_from_trackers
@@ -71,6 +72,7 @@ class FritzBoxTracker(FritzDeviceBase, ScannerEntity):
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Initialize a FRITZ!Box device."""
super().__init__(avm_wrapper, device)
self._attr_name: str = device.hostname or DEFAULT_DEVICE_NAME
self._last_activity: datetime.datetime | None = device.last_activity
@property

View File

@@ -13,7 +13,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_DEVICE_NAME, DOMAIN
from .const import DOMAIN
from .coordinator import AvmWrapper
from .models import FritzDevice
@@ -21,21 +21,17 @@ from .models import FritzDevice
class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):
"""Entity base class for a device connected to a FRITZ!Box device."""
_attr_has_entity_name = True
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Initialize a FRITZ!Box device."""
super().__init__(avm_wrapper)
self._avm_wrapper = avm_wrapper
self._mac: str = device.mac_address
self._name: str = device.hostname or DEFAULT_DEVICE_NAME
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}
)
@property
def name(self) -> str:
"""Return device name."""
return self._name
@property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""

View File

@@ -3,6 +3,9 @@
"button": {
"cleanup": {
"default": "mdi:broom"
},
"wake_on_lan": {
"default": "mdi:lan-pending"
}
},
"sensor": {
@@ -48,6 +51,11 @@
"max_kb_s_sent": {
"default": "mdi:upload"
}
},
"switch": {
"internet_access": {
"default": "mdi:router-wireless-settings"
}
}
},
"services": {

View File

@@ -8,6 +8,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
"quality_scale": "bronze",
"requirements": ["fritzconnection[qr]==1.15.0", "xmltodict==1.0.2"],
"ssdp": [
{

View File

@@ -13,9 +13,7 @@ rules:
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name:
status: todo
comment: partially done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done

View File

@@ -108,6 +108,9 @@
},
"reconnect": {
"name": "Reconnect"
},
"wake_on_lan": {
"name": "Wake on LAN"
}
},
"sensor": {
@@ -162,6 +165,11 @@
"max_kb_s_sent": {
"name": "Max connection upload throughput"
}
},
"switch": {
"internet_access": {
"name": "Internet access"
}
}
},
"exceptions": {

View File

@@ -499,13 +499,12 @@ class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
"""Defines a FRITZ!Box Tools DeviceProfile switch."""
_attr_icon = "mdi:router-wireless-settings"
_attr_translation_key = "internet_access"
def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None:
"""Init Fritz profile."""
super().__init__(avm_wrapper, device)
self._attr_is_on: bool = False
self._name = f"{device.hostname} Internet Access"
self._attr_unique_id = f"{self._mac}_internet_access"
self._attr_entity_category = EntityCategory.CONFIG

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260107.0"]
"requirements": ["home-assistant-frontend==20260107.1"]
}

View File

@@ -1,9 +1,21 @@
{
"entity": {
"sensor": {
"ammonia": {
"default": "mdi:molecule"
},
"benzene": {
"default": "mdi:molecule"
},
"nitrogen_dioxide": {
"default": "mdi:molecule"
},
"nitrogen_monoxide": {
"default": "mdi:molecule"
},
"non_methane_hydrocarbons": {
"default": "mdi:molecule"
},
"ozone": {
"default": "mdi:molecule"
},

View File

@@ -99,6 +99,14 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
"local_aqi": data.indexes[1].display_name
},
),
AirQualitySensorEntityDescription(
key="c6h6",
translation_key="benzene",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.c6h6.concentration.units,
value_fn=lambda x: x.pollutants.c6h6.concentration.value,
exists_fn=lambda x: "c6h6" in {p.code for p in x.pollutants},
),
AirQualitySensorEntityDescription(
key="co",
state_class=SensorStateClass.MEASUREMENT,
@@ -106,6 +114,30 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
native_unit_of_measurement_fn=lambda x: x.pollutants.co.concentration.units,
value_fn=lambda x: x.pollutants.co.concentration.value,
),
AirQualitySensorEntityDescription(
key="nh3",
translation_key="ammonia",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.nh3.concentration.units,
value_fn=lambda x: x.pollutants.nh3.concentration.value,
exists_fn=lambda x: "nh3" in {p.code for p in x.pollutants},
),
AirQualitySensorEntityDescription(
key="nmhc",
translation_key="non_methane_hydrocarbons",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.nmhc.concentration.units,
value_fn=lambda x: x.pollutants.nmhc.concentration.value,
exists_fn=lambda x: "nmhc" in {p.code for p in x.pollutants},
),
AirQualitySensorEntityDescription(
key="no",
translation_key="nitrogen_monoxide",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement_fn=lambda x: x.pollutants.no.concentration.units,
value_fn=lambda x: x.pollutants.no.concentration.value,
exists_fn=lambda x: "no" in {p.code for p in x.pollutants},
),
AirQualitySensorEntityDescription(
key="no2",
translation_key="nitrogen_dioxide",

View File

@@ -76,6 +76,12 @@
},
"entity": {
"sensor": {
"ammonia": {
"name": "Ammonia"
},
"benzene": {
"name": "Benzene"
},
"local_aqi": {
"name": "{local_aqi} AQI"
},
@@ -189,6 +195,9 @@
"name": "{local_aqi} dominant pollutant",
"state": {
"co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"nh3": "[%key:component::google_air_quality::entity::sensor::ammonia::name%]",
"nmhc": "[%key:component::google_air_quality::entity::sensor::non_methane_hydrocarbons::name%]",
"no": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"o3": "[%key:component::sensor::entity_component::ozone::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
@@ -199,6 +208,12 @@
"nitrogen_dioxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
},
"nitrogen_monoxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]"
},
"non_methane_hydrocarbons": {
"name": "Non-methane hydrocarbons"
},
"ozone": {
"name": "[%key:component::sensor::entity_component::ozone::name%]"
},

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/gree",
"iot_class": "local_polling",
"loggers": ["greeclimate"],
"requirements": ["greeclimate==2.1.0"]
"requirements": ["greeclimate==2.1.1"]
}

View File

@@ -0,0 +1,21 @@
"""Diagnostics for HDFury Integration."""
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .coordinator import HDFuryCoordinator
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: HDFuryCoordinator = entry.runtime_data
return {
"board": coordinator.data.board,
"info": coordinator.data.info,
"config": coordinator.data.config,
}

View File

@@ -43,7 +43,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: todo

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from pyhik.constants import SENSOR_MAP
from pyhik.hikvision import HikCamera
import requests
@@ -19,10 +20,13 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA]
@dataclass
@@ -70,19 +74,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
device_type=device_type,
)
_LOGGER.debug(
"Device %s (type=%s) initial event_states: %s",
device_name,
device_type,
camera.current_event_states,
)
# For NVRs or devices with no detected events, try to fetch events from ISAPI
# Use broader notification methods for NVRs since they often use 'record' etc.
if device_type == "NVR" or not camera.current_event_states:
nvr_notification_methods = {"center", "HTTP", "record", "email", "beep"}
def fetch_and_inject_nvr_events() -> None:
"""Fetch and inject NVR events in a single executor job."""
if nvr_events := camera.get_event_triggers():
camera.inject_events(nvr_events)
nvr_events = camera.get_event_triggers(nvr_notification_methods)
_LOGGER.debug("NVR events fetched with extended methods: %s", nvr_events)
if nvr_events:
# Map raw event type names to friendly names using SENSOR_MAP
mapped_events: dict[str, list[int]] = {}
for event_type, channels in nvr_events.items():
friendly_name = SENSOR_MAP.get(event_type.lower(), event_type)
if friendly_name in mapped_events:
mapped_events[friendly_name].extend(channels)
else:
mapped_events[friendly_name] = list(channels)
_LOGGER.debug("Mapped NVR events: %s", mapped_events)
camera.inject_events(mapped_events)
await hass.async_add_executor_job(fetch_and_inject_nvr_events)
# Start the event stream
await hass.async_add_executor_job(camera.start_stream)
# Register the main device before platforms that use via_device
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, device_id)},
name=device_name,
manufacturer="Hikvision",
model=device_type,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -185,19 +185,26 @@ class HikvisionBinarySensor(BinarySensorEntity):
# Build unique ID
self._attr_unique_id = f"{self._data.device_id}_{sensor_type}_{channel}"
# Build entity name based on device type
if self._data.device_type == "NVR":
self._attr_name = f"{sensor_type} {channel}"
else:
self._attr_name = sensor_type
# Device info for device registry
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._data.device_id)},
name=self._data.device_name,
manufacturer="Hikvision",
model=self._data.device_type,
)
if self._data.device_type == "NVR":
# NVR channels get their own device linked to the NVR via via_device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
via_device=(DOMAIN, self._data.device_id),
name=f"{self._data.device_name} Channel {channel}",
manufacturer="Hikvision",
model="NVR Channel",
)
self._attr_name = sensor_type
else:
# Single camera device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._data.device_id)},
name=self._data.device_name,
manufacturer="Hikvision",
model=self._data.device_type,
)
self._attr_name = sensor_type
# Set device class
self._attr_device_class = DEVICE_CLASS_MAP.get(sensor_type)

View File

@@ -0,0 +1,93 @@
"""Support for Hikvision cameras."""
from __future__ import annotations
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HikvisionConfigEntry
from .const import DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: HikvisionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hikvision cameras from a config entry."""
data = entry.runtime_data
camera = data.camera
# Get available channels from the library
channels = await hass.async_add_executor_job(camera.get_channels)
if channels:
entities = [HikvisionCamera(entry, channel) for channel in channels]
else:
# Fallback to single camera if no channels detected
entities = [HikvisionCamera(entry, 1)]
async_add_entities(entities)
class HikvisionCamera(Camera):
"""Representation of a Hikvision camera."""
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = CameraEntityFeature.STREAM
def __init__(
self,
entry: HikvisionConfigEntry,
channel: int,
) -> None:
"""Initialize the camera."""
super().__init__()
self._data = entry.runtime_data
self._channel = channel
self._camera = self._data.camera
# Build unique ID (unique per platform per integration)
self._attr_unique_id = f"{self._data.device_id}_{channel}"
# Device info for device registry
if self._data.device_type == "NVR":
# NVR channels get their own device linked to the NVR via via_device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._data.device_id}_{channel}")},
via_device=(DOMAIN, self._data.device_id),
name=f"{self._data.device_name} Channel {channel}",
manufacturer="Hikvision",
model="NVR Channel",
)
else:
# Single camera device
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._data.device_id)},
name=self._data.device_name,
manufacturer="Hikvision",
model=self._data.device_type,
)
async def async_camera_image(
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image from the camera."""
try:
return await self.hass.async_add_executor_job(
self._camera.get_snapshot, self._channel
)
except Exception as err:
raise HomeAssistantError(
f"Error getting image from {self._data.device_name} channel {self._channel}: {err}"
) from err
async def stream_source(self) -> str | None:
"""Return the stream source URL."""
return self._camera.get_stream_url(self._channel)

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["pyhik"],
"quality_scale": "legacy",
"requirements": ["pyHik==0.3.4"]
"requirements": ["pyHik==0.4.0"]
}

View File

@@ -13,6 +13,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
"requirements": ["python-homewizard-energy==10.0.0"],
"requirements": ["python-homewizard-energy==10.0.1"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}

View File

@@ -27,7 +27,7 @@ from .const import (
SUPPORTED_PLATFORMS_UI,
SUPPORTED_PLATFORMS_YAML,
)
from .expose import create_knx_exposure
from .expose import create_combined_knx_exposure
from .knx_module import KNXModule
from .project import STORAGE_KEY as PROJECT_STORAGE_KEY
from .schema import (
@@ -121,10 +121,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[KNX_MODULE_KEY] = knx_module
if CONF_KNX_EXPOSE in config:
for expose_config in config[CONF_KNX_EXPOSE]:
knx_module.exposures.append(
create_knx_exposure(hass, knx_module.xknx, expose_config)
)
knx_module.yaml_exposures.extend(
create_combined_knx_exposure(hass, knx_module.xknx, config[CONF_KNX_EXPOSE])
)
configured_platforms_yaml = {
platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config
}
@@ -149,7 +149,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# if not loaded directly return
return True
for exposure in knx_module.exposures:
for exposure in knx_module.yaml_exposures:
exposure.async_remove()
for exposure in knx_module.service_exposures.values():
exposure.async_remove()
configured_platforms_yaml = {

View File

@@ -2,14 +2,22 @@
from __future__ import annotations
from collections.abc import Callable
from asyncio import TaskGroup
from collections.abc import Callable, Iterable
from dataclasses import dataclass
import logging
from typing import Any
from xknx import XKNX
from xknx.devices import DateDevice, DateTimeDevice, ExposeSensor, TimeDevice
from xknx.dpt import DPTNumeric, DPTString
from xknx.dpt import DPTBase, DPTNumeric, DPTString
from xknx.dpt.dpt_1 import DPT1BitEnum, DPTSwitch
from xknx.exceptions import ConversionError
from xknx.remote_value import RemoteValueSensor
from xknx.telegram.address import (
GroupAddress,
InternalGroupAddress,
parse_device_group_address,
)
from homeassistant.const import (
CONF_ENTITY_ID,
@@ -41,79 +49,159 @@ _LOGGER = logging.getLogger(__name__)
@callback
def create_knx_exposure(
hass: HomeAssistant, xknx: XKNX, config: ConfigType
) -> KNXExposeSensor | KNXExposeTime:
"""Create exposures from config."""
) -> KnxExposeEntity | KnxExposeTime:
"""Create single exposure."""
expose_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
exposure: KNXExposeSensor | KNXExposeTime
exposure: KnxExposeEntity | KnxExposeTime
if (
isinstance(expose_type, str)
and expose_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES
):
exposure = KNXExposeTime(
exposure = KnxExposeTime(
xknx=xknx,
config=config,
)
else:
exposure = KNXExposeSensor(
hass,
exposure = KnxExposeEntity(
hass=hass,
xknx=xknx,
config=config,
entity_id=config[CONF_ENTITY_ID],
options=(_yaml_config_to_expose_options(config),),
)
exposure.async_register()
return exposure
class KNXExposeSensor:
"""Object to Expose Home Assistant entity to KNX bus."""
@callback
def create_combined_knx_exposure(
hass: HomeAssistant, xknx: XKNX, configs: list[ConfigType]
) -> list[KnxExposeEntity | KnxExposeTime]:
"""Create exposures from YAML config combined by entity_id."""
exposures: list[KnxExposeEntity | KnxExposeTime] = []
entity_exposure_map: dict[str, list[KnxExposeOptions]] = {}
for config in configs:
value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
if value_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES:
time_exposure = KnxExposeTime(
xknx=xknx,
config=config,
)
time_exposure.async_register()
exposures.append(time_exposure)
continue
entity_id = config[CONF_ENTITY_ID]
option = _yaml_config_to_expose_options(config)
entity_exposure_map.setdefault(entity_id, []).append(option)
for entity_id, options in entity_exposure_map.items():
entity_exposure = KnxExposeEntity(
hass=hass,
xknx=xknx,
entity_id=entity_id,
options=options,
)
entity_exposure.async_register()
exposures.append(entity_exposure)
return exposures
@dataclass(slots=True)
class KnxExposeOptions:
"""Options for KNX Expose."""
attribute: str | None
group_address: GroupAddress | InternalGroupAddress
dpt: type[DPTBase]
respond_to_read: bool
cooldown: float
default: Any | None
value_template: Template | None
def _yaml_config_to_expose_options(config: ConfigType) -> KnxExposeOptions:
"""Convert single yaml expose config to KnxExposeOptions."""
value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
dpt: type[DPTBase]
if value_type == "binary":
# HA yaml expose flag for DPT-1 (no explicit DPT 1 definitions in xknx back then)
dpt = DPTSwitch
else:
dpt = DPTBase.parse_transcoder(config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]) # type: ignore[assignment] # checked by schema validation
ga = parse_device_group_address(config[KNX_ADDRESS])
return KnxExposeOptions(
attribute=config.get(ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE),
group_address=ga,
dpt=dpt,
respond_to_read=config[CONF_RESPOND_TO_READ],
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
default=config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT),
value_template=config.get(CONF_VALUE_TEMPLATE),
)
class KnxExposeEntity:
"""Expose Home Assistant entity values to KNX bus."""
def __init__(
self,
hass: HomeAssistant,
xknx: XKNX,
config: ConfigType,
entity_id: str,
options: Iterable[KnxExposeOptions],
) -> None:
"""Initialize of Expose class."""
"""Initialize KnxExposeEntity class."""
self.hass = hass
self.xknx = xknx
self.entity_id: str = config[CONF_ENTITY_ID]
self.expose_attribute: str | None = config.get(
ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE
)
self.expose_default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT)
self.expose_type: int | str = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
self.value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
self.entity_id = entity_id
self._remove_listener: Callable[[], None] | None = None
self.device: ExposeSensor = ExposeSensor(
xknx=self.xknx,
name=f"{self.entity_id}__{self.expose_attribute or 'state'}",
group_address=config[KNX_ADDRESS],
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=self.expose_type,
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
self._exposures = tuple(
(
option,
ExposeSensor(
xknx=self.xknx,
name=f"{self.entity_id} {option.attribute or 'state'}",
group_address=option.group_address,
respond_to_read=option.respond_to_read,
value_type=option.dpt,
cooldown=option.cooldown,
),
)
for option in options
)
@property
def name(self) -> str:
"""Return name of the expose entity."""
expose_names = [opt.attribute or "state" for opt, _ in self._exposures]
return f"{self.entity_id}__{'__'.join(expose_names)}"
@callback
def async_register(self) -> None:
"""Register listener."""
"""Register listener and XKNX devices."""
self._remove_listener = async_track_state_change_event(
self.hass, [self.entity_id], self._async_entity_changed
)
self.xknx.devices.async_add(self.device)
for _option, xknx_expose in self._exposures:
self.xknx.devices.async_add(xknx_expose)
self._init_expose_state()
@callback
def _init_expose_state(self) -> None:
"""Initialize state of the exposure."""
"""Initialize state of all exposures."""
init_state = self.hass.states.get(self.entity_id)
state_value = self._get_expose_value(init_state)
try:
self.device.sensor_value.value = state_value
except ConversionError:
_LOGGER.exception("Error during sending of expose sensor value")
for option, xknx_expose in self._exposures:
state_value = self._get_expose_value(init_state, option)
try:
xknx_expose.sensor_value.value = state_value
except ConversionError:
_LOGGER.exception(
"Error setting value %s for expose sensor %s",
state_value,
xknx_expose.name,
)
@callback
def async_remove(self) -> None:
@@ -121,53 +209,57 @@ class KNXExposeSensor:
if self._remove_listener is not None:
self._remove_listener()
self._remove_listener = None
self.xknx.devices.async_remove(self.device)
for _option, xknx_expose in self._exposures:
self.xknx.devices.async_remove(xknx_expose)
def _get_expose_value(self, state: State | None) -> bool | int | float | str | None:
"""Extract value from state."""
def _get_expose_value(
self, state: State | None, option: KnxExposeOptions
) -> bool | int | float | str | None:
"""Extract value from state for a specific option."""
if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
if self.expose_default is None:
if option.default is None:
return None
value = self.expose_default
elif self.expose_attribute is not None:
_attr = state.attributes.get(self.expose_attribute)
value = _attr if _attr is not None else self.expose_default
value = option.default
elif option.attribute is not None:
_attr = state.attributes.get(option.attribute)
value = _attr if _attr is not None else option.default
else:
value = state.state
if self.value_template is not None:
if option.value_template is not None:
try:
value = self.value_template.async_render_with_possible_json_value(
value = option.value_template.async_render_with_possible_json_value(
value, error_value=None
)
except (TemplateError, TypeError, ValueError) as err:
_LOGGER.warning(
"Error rendering value template for KNX expose %s %s: %s",
self.device.name,
self.value_template.template,
"Error rendering value template for KNX expose %s %s %s: %s",
self.entity_id,
option.attribute or "state",
option.value_template.template,
err,
)
return None
if self.expose_type == "binary":
if issubclass(option.dpt, DPT1BitEnum):
if value in (1, STATE_ON, "True"):
return True
if value in (0, STATE_OFF, "False"):
return False
if value is not None and (
isinstance(self.device.sensor_value, RemoteValueSensor)
):
# Handle numeric and string DPT conversions
if value is not None:
try:
if issubclass(self.device.sensor_value.dpt_class, DPTNumeric):
if issubclass(option.dpt, DPTNumeric):
return float(value)
if issubclass(self.device.sensor_value.dpt_class, DPTString):
if issubclass(option.dpt, DPTString):
# DPT 16.000 only allows up to 14 Bytes
return str(value)[:14]
except (ValueError, TypeError) as err:
_LOGGER.warning(
'Could not expose %s %s value "%s" to KNX: Conversion failed: %s',
self.entity_id,
self.expose_attribute or "state",
option.attribute or "state",
value,
err,
)
@@ -175,32 +267,31 @@ class KNXExposeSensor:
return value # type: ignore[no-any-return]
async def _async_entity_changed(self, event: Event[EventStateChangedData]) -> None:
"""Handle entity change."""
"""Handle entity change for all options."""
new_state = event.data["new_state"]
if (new_value := self._get_expose_value(new_state)) is None:
return
old_state = event.data["old_state"]
# don't use default value for comparison on first state change (old_state is None)
old_value = self._get_expose_value(old_state) if old_state is not None else None
# don't send same value sequentially
if new_value != old_value:
await self._async_set_knx_value(new_value)
async with TaskGroup() as tg:
for option, xknx_expose in self._exposures:
expose_value = self._get_expose_value(new_state, option)
if expose_value is None:
continue
tg.create_task(self._async_set_knx_value(xknx_expose, expose_value))
async def _async_set_knx_value(self, value: StateType) -> None:
async def _async_set_knx_value(
self, xknx_expose: ExposeSensor, value: StateType
) -> None:
"""Set new value on xknx ExposeSensor."""
try:
await self.device.set(value)
await xknx_expose.set(value, skip_unchanged=True)
except ConversionError as err:
_LOGGER.warning(
'Could not expose %s %s value "%s" to KNX: %s',
self.entity_id,
self.expose_attribute or "state",
'Could not expose %s value "%s" to KNX: %s',
xknx_expose.name,
value,
err,
)
class KNXExposeTime:
class KnxExposeTime:
"""Object to Expose Time/Date object to KNX bus."""
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
@@ -222,6 +313,11 @@ class KNXExposeTime:
group_address=config[KNX_ADDRESS],
)
@property
def name(self) -> str:
"""Return name of the time expose object."""
return f"expose_{self.device.name}"
@callback
def async_register(self) -> None:
"""Register listener."""

View File

@@ -54,7 +54,7 @@ from .const import (
TELEGRAM_LOG_DEFAULT,
)
from .device import KNXInterfaceDevice
from .expose import KNXExposeSensor, KNXExposeTime
from .expose import KnxExposeEntity, KnxExposeTime
from .project import KNXProject
from .repairs import data_secure_group_key_issue_dispatcher
from .storage.config_store import KNXConfigStore
@@ -73,8 +73,8 @@ class KNXModule:
self.hass = hass
self.config_yaml = config
self.connected = False
self.exposures: list[KNXExposeSensor | KNXExposeTime] = []
self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {}
self.yaml_exposures: list[KnxExposeEntity | KnxExposeTime] = []
self.service_exposures: dict[str, KnxExposeEntity | KnxExposeTime] = {}
self.entry = entry
self.project = KNXProject(hass=hass, entry=entry)

View File

@@ -11,7 +11,7 @@
"loggers": ["xknx", "xknxproject"],
"quality_scale": "platinum",
"requirements": [
"xknx==3.13.0",
"xknx==3.14.0",
"xknxproject==3.8.2",
"knx-frontend==2025.12.30.151231"
],

View File

@@ -193,7 +193,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
" for '%s' - %s"
),
group_address,
replaced_exposure.device.name,
replaced_exposure.name,
)
replaced_exposure.async_remove()
exposure = create_knx_exposure(knx_module.hass, knx_module.xknx, call.data)
@@ -201,7 +201,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
_LOGGER.debug(
"Service exposure_register registered exposure for '%s' - %s",
group_address,
exposure.device.name,
exposure.name,
)

View File

@@ -256,6 +256,8 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R)
and WidgetType.CM_BREW_BY_WEIGHT_DOSES
in coordinator.device.dashboard.config
),
),
LaMarzoccoNumberEntityDescription(
@@ -289,6 +291,8 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R)
and WidgetType.CM_BREW_BY_WEIGHT_DOSES
in coordinator.device.dashboard.config
),
),
)

View File

@@ -149,6 +149,8 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MINI, ModelName.LINEA_MINI_R)
and WidgetType.CM_BREW_BY_WEIGHT_DOSES
in coordinator.device.dashboard.config
),
),
)

View File

@@ -42,7 +42,7 @@
},
"conditions": {
"is_off": {
"description": "Test if a light is off.",
"description": "Tests if one or more lights are off.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
@@ -52,7 +52,7 @@
"name": "If a light is off"
},
"is_on": {
"description": "Test if a light is on.",
"description": "Tests if one or more lights are on.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",

View File

@@ -66,8 +66,9 @@ class MatterRangeNumberEntityDescription(
format_max_value: Callable[[float], float] = lambda x: x
# command: a custom callback to create the command to send to the device
# the callback's argument will be the index of the selected list value
command: Callable[[int], ClusterCommand]
# the callback's argument will be the converted device value from ha_to_device
# if omitted the command will just be a write_attribute command to the primary attribute
command: Callable[[int], ClusterCommand] | None = None
class MatterNumber(MatterEntity, NumberEntity):
@@ -99,9 +100,15 @@ class MatterRangeNumber(MatterEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
send_value = self.entity_description.ha_to_device(value)
# custom command defined to set the new value
await self.send_device_command(
self.entity_description.command(send_value),
if self.entity_description.command:
# custom command defined to set the new value
await self.send_device_command(
self.entity_description.command(send_value),
)
return
# regular write attribute to set the new value
await self.write_attribute(
value=send_value,
)
@callback
@@ -253,6 +260,30 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterNumber,
required_attributes=(custom_clusters.EveCluster.Attributes.Altitude,),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterRangeNumberEntityDescription(
key="ThermostatOccupiedSetback",
entity_category=EntityCategory.CONFIG,
translation_key="occupied_setback",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_to_ha=lambda x: None if x is None else x / 10,
ha_to_device=lambda x: round(x * 10),
format_min_value=lambda x: x / 10,
format_max_value=lambda x: x / 10,
min_attribute=clusters.Thermostat.Attributes.OccupiedSetbackMin,
max_attribute=clusters.Thermostat.Attributes.OccupiedSetbackMax,
native_step=0.5,
mode=NumberMode.BOX,
),
entity_class=MatterRangeNumber,
required_attributes=(
clusters.Thermostat.Attributes.OccupiedSetback,
clusters.Thermostat.Attributes.OccupiedSetbackMin,
clusters.Thermostat.Attributes.OccupiedSetbackMax,
),
featuremap_contains=(clusters.Thermostat.Bitmaps.Feature.kSetback),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(

View File

@@ -217,6 +217,9 @@
"led_indicator_intensity_on": {
"name": "LED on intensity"
},
"occupied_setback": {
"name": "Occupied setback"
},
"off_transition_time": {
"name": "Off transition time"
},

View File

@@ -4,45 +4,64 @@ from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any
from aiohttp import ClientConnectionError, ClientResponseError
from pymelcloud import Device, get_devices
from pymelcloud.atw_device import Zone
from pymelcloud import get_devices
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.util import Throttle
from homeassistant.helpers.update_coordinator import UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDevice]]]
async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool:
"""Establish connection with MELCloud."""
conf = entry.data
try:
mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN])
except ClientResponseError as ex:
if isinstance(ex, ClientResponseError) and ex.code == 401:
raise ConfigEntryAuthFailed from ex
raise ConfigEntryNotReady from ex
except (TimeoutError, ClientConnectionError) as ex:
raise ConfigEntryNotReady from ex
token = entry.data[CONF_TOKEN]
session = async_get_clientsession(hass)
entry.runtime_data = mel_devices
try:
async with asyncio.timeout(10):
all_devices = await get_devices(
token,
session,
conf_update_interval=timedelta(minutes=30),
device_set_debounce=timedelta(seconds=2),
)
except ClientResponseError as ex:
if ex.status in (401, 403):
raise ConfigEntryAuthFailed from ex
if ex.status == 429:
raise UpdateFailed(
"MELCloud rate limit exceeded. Your account may be temporarily blocked"
) from ex
raise UpdateFailed(f"Error communicating with MELCloud: {ex}") from ex
except (TimeoutError, ClientConnectionError) as ex:
raise UpdateFailed(f"Error communicating with MELCloud: {ex}") from ex
# Create per-device coordinators
coordinators: dict[str, list[MelCloudDeviceUpdateCoordinator]] = {}
device_registry = dr.async_get(hass)
for device_type, devices in all_devices.items():
coordinators[device_type] = []
for device in devices:
coordinator = MelCloudDeviceUpdateCoordinator(hass, device, entry)
# Perform initial refresh for this device
await coordinator.async_config_entry_first_refresh()
coordinators[device_type].append(coordinator)
# Register parent device now so zone entities can reference it via via_device
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
**coordinator.device_info,
)
entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -50,90 +69,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
class MelCloudDevice:
"""MELCloud Device instance."""
def __init__(self, device: Device) -> None:
"""Construct a device wrapper."""
self.device = device
self.name = device.name
self._available = True
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self, **kwargs):
"""Pull the latest data from MELCloud."""
try:
await self.device.update()
self._available = True
except ClientConnectionError:
_LOGGER.warning("Connection failed for %s", self.name)
self._available = False
async def async_set(self, properties: dict[str, Any]):
"""Write state changes to the MELCloud API."""
try:
await self.device.set(properties)
self._available = True
except ClientConnectionError:
_LOGGER.warning("Connection failed for %s", self.name)
self._available = False
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._available
@property
def device_id(self):
"""Return device ID."""
return self.device.device_id
@property
def building_id(self):
"""Return building ID of the device."""
return self.device.building_id
@property
def device_info(self) -> DeviceInfo:
"""Return a device description for device registry."""
model = None
if (unit_infos := self.device.units) is not None:
model = ", ".join([x["model"] for x in unit_infos if x["model"]])
return DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, self.device.mac)},
identifiers={(DOMAIN, f"{self.device.mac}-{self.device.serial}")},
manufacturer="Mitsubishi Electric",
model=model,
name=self.name,
)
def zone_device_info(self, zone: Zone) -> DeviceInfo:
"""Return a zone device description for device registry."""
dev = self.device
return DeviceInfo(
identifiers={(DOMAIN, f"{dev.mac}-{dev.serial}-{zone.zone_index}")},
manufacturer="Mitsubishi Electric",
model="ATW zone device",
name=f"{self.name} {zone.name}",
via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"),
)
async def mel_devices_setup(
hass: HomeAssistant, token: str
) -> dict[str, list[MelCloudDevice]]:
"""Query connected devices from MELCloud."""
session = async_get_clientsession(hass)
async with asyncio.timeout(10):
all_devices = await get_devices(
token,
session,
conf_update_interval=timedelta(minutes=30),
device_set_debounce=timedelta(seconds=2),
)
wrapped_devices: dict[str, list[MelCloudDevice]] = {}
for device_type, devices in all_devices.items():
wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices]
return wrapped_devices

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any, cast
from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice
@@ -29,7 +28,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudConfigEntry, MelCloudDevice
from .const import (
ATTR_STATUS,
ATTR_VANE_HORIZONTAL,
@@ -40,9 +38,8 @@ from .const import (
SERVICE_SET_VANE_HORIZONTAL,
SERVICE_SET_VANE_VERTICAL,
)
SCAN_INTERVAL = timedelta(seconds=60)
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
from .entity import MelCloudEntity
ATA_HVAC_MODE_LOOKUP = {
ata.OPERATION_MODE_HEAT: HVACMode.HEAT,
@@ -74,27 +71,24 @@ ATW_ZONE_HVAC_ACTION_LOOKUP = {
async def async_setup_entry(
hass: HomeAssistant,
_hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
mel_devices = entry.runtime_data
coordinators = entry.runtime_data
entities: list[AtaDeviceClimate | AtwDeviceZoneClimate] = [
AtaDeviceClimate(mel_device, mel_device.device)
for mel_device in mel_devices[DEVICE_TYPE_ATA]
AtaDeviceClimate(coordinator, coordinator.device)
for coordinator in coordinators.get(DEVICE_TYPE_ATA, [])
]
entities.extend(
[
AtwDeviceZoneClimate(mel_device, mel_device.device, zone)
for mel_device in mel_devices[DEVICE_TYPE_ATW]
for zone in mel_device.device.zones
AtwDeviceZoneClimate(coordinator, coordinator.device, zone)
for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
for zone in coordinator.device.zones
]
)
async_add_entities(
entities,
True,
)
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
@@ -109,21 +103,19 @@ async def async_setup_entry(
)
class MelCloudClimate(ClimateEntity):
class MelCloudClimate(MelCloudEntity, ClimateEntity):
"""Base climate device."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_name = None
def __init__(self, device: MelCloudDevice) -> None:
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
) -> None:
"""Initialize the climate."""
self.api = device
self._base_device = self.api.device
async def async_update(self) -> None:
"""Update state from MELCloud."""
await self.api.async_update()
super().__init__(coordinator)
self._base_device = self.coordinator.device
@property
def target_temperature_step(self) -> float | None:
@@ -142,26 +134,29 @@ class AtaDeviceClimate(MelCloudClimate):
| ClimateEntityFeature.TURN_ON
)
def __init__(self, device: MelCloudDevice, ata_device: AtaDevice) -> None:
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
ata_device: AtaDevice,
) -> None:
"""Initialize the climate."""
super().__init__(device)
super().__init__(coordinator)
self._device = ata_device
self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}"
self._attr_device_info = self.api.device_info
self._attr_unique_id = (
f"{self.coordinator.device.serial}-{self.coordinator.device.mac}"
)
self._attr_device_info = self.coordinator.device_info
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
# We can only check for vane_horizontal once we fetch the device data from the cloud
# Add horizontal swing if device supports it
if self._device.vane_horizontal:
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the optional state attributes with device specific additions."""
attr = {}
attr: dict[str, Any] = {}
attr.update(self.coordinator.extra_attributes)
if vane_horizontal := self._device.vane_horizontal:
attr.update(
@@ -208,7 +203,7 @@ class AtaDeviceClimate(MelCloudClimate):
"""Set new target hvac mode."""
set_dict: dict[str, Any] = {}
self._apply_set_hvac_mode(hvac_mode, set_dict)
await self._device.set(set_dict)
await self.coordinator.async_set(set_dict)
@property
def hvac_modes(self) -> list[HVACMode]:
@@ -241,7 +236,7 @@ class AtaDeviceClimate(MelCloudClimate):
set_dict["target_temperature"] = kwargs.get(ATTR_TEMPERATURE)
if set_dict:
await self._device.set(set_dict)
await self.coordinator.async_set(set_dict)
@property
def fan_mode(self) -> str | None:
@@ -250,7 +245,7 @@ class AtaDeviceClimate(MelCloudClimate):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
await self._device.set({"fan_speed": fan_mode})
await self.coordinator.async_set({"fan_speed": fan_mode})
@property
def fan_modes(self) -> list[str] | None:
@@ -264,7 +259,7 @@ class AtaDeviceClimate(MelCloudClimate):
f"Invalid horizontal vane position {position}. Valid positions:"
f" [{self._device.vane_horizontal_positions}]."
)
await self._device.set({ata.PROPERTY_VANE_HORIZONTAL: position})
await self.coordinator.async_set({ata.PROPERTY_VANE_HORIZONTAL: position})
async def async_set_vane_vertical(self, position: str) -> None:
"""Set vertical vane position."""
@@ -273,7 +268,7 @@ class AtaDeviceClimate(MelCloudClimate):
f"Invalid vertical vane position {position}. Valid positions:"
f" [{self._device.vane_vertical_positions}]."
)
await self._device.set({ata.PROPERTY_VANE_VERTICAL: position})
await self.coordinator.async_set({ata.PROPERTY_VANE_VERTICAL: position})
@property
def swing_mode(self) -> str | None:
@@ -305,11 +300,11 @@ class AtaDeviceClimate(MelCloudClimate):
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self._device.set({"power": True})
await self.coordinator.async_set({"power": True})
async def async_turn_off(self) -> None:
"""Turn the entity off."""
await self._device.set({"power": False})
await self.coordinator.async_set({"power": False})
@property
def min_temp(self) -> float:
@@ -338,15 +333,18 @@ class AtwDeviceZoneClimate(MelCloudClimate):
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
def __init__(
self, device: MelCloudDevice, atw_device: AtwDevice, atw_zone: Zone
self,
coordinator: MelCloudDeviceUpdateCoordinator,
atw_device: AtwDevice,
atw_zone: Zone,
) -> None:
"""Initialize the climate."""
super().__init__(device)
super().__init__(coordinator)
self._device = atw_device
self._zone = atw_zone
self._attr_unique_id = f"{self.api.device.serial}-{atw_zone.zone_index}"
self._attr_device_info = self.api.zone_device_info(atw_zone)
self._attr_unique_id = f"{self.coordinator.device.serial}-{atw_zone.zone_index}"
self._attr_device_info = self.coordinator.zone_device_info(atw_zone)
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -360,15 +358,16 @@ class AtwDeviceZoneClimate(MelCloudClimate):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
mode = self._zone.operation_mode
if not self._device.power or mode is None:
# Use zone status (heat/cool/idle) not operation_mode (heat-thermostat/etc.)
status = self._zone.status
if not self._device.power or status is None:
return HVACMode.OFF
return ATW_ZONE_HVAC_MODE_LOOKUP.get(mode, HVACMode.OFF)
return ATW_ZONE_HVAC_MODE_LOOKUP.get(status, HVACMode.OFF)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.OFF:
await self._device.set({"power": False})
await self.coordinator.async_set({"power": False})
return
operation_mode = ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode)
@@ -381,7 +380,7 @@ class AtwDeviceZoneClimate(MelCloudClimate):
props = {PROPERTY_ZONE_2_OPERATION_MODE: operation_mode}
if self.hvac_mode == HVACMode.OFF:
props["power"] = True
await self._device.set(props)
await self.coordinator.async_set(props)
@property
def hvac_modes(self) -> list[HVACMode]:
@@ -410,3 +409,4 @@ class AtwDeviceZoneClimate(MelCloudClimate):
await self._zone.set_target_temperature(
kwargs.get(ATTR_TEMPERATURE, self.target_temperature)
)
await self.coordinator.async_request_refresh()

View File

@@ -60,6 +60,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="cannot_connect")
except (TimeoutError, ClientError):
return self.async_abort(reason="cannot_connect")
except AttributeError:
# python-melcloud library bug: login() raises AttributeError on invalid
# credentials when API response doesn't contain expected "LoginData" key
return self.async_abort(reason="invalid_auth")
return await self._create_entry(username, acquired_token)

View File

@@ -0,0 +1,193 @@
"""DataUpdateCoordinator for the MELCloud integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from aiohttp import ClientConnectionError, ClientResponseError
from pymelcloud import Device
from pymelcloud.atw_device import Zone
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# Delay before refreshing after a state change to allow device to process
# and avoid race conditions with rapid sequential changes
REQUEST_REFRESH_DELAY = 1.5
# Default update interval in minutes (matches upstream Throttle value)
DEFAULT_UPDATE_INTERVAL = 15
# Retry interval in seconds for transient failures
RETRY_INTERVAL_SECONDS = 30
# Number of consecutive failures before marking device unavailable
MAX_CONSECUTIVE_FAILURES = 3
class MelCloudDeviceUpdateCoordinator(DataUpdateCoordinator[None]):
"""Per-device coordinator for MELCloud data updates."""
def __init__(
self,
hass: HomeAssistant,
device: Device,
config_entry: ConfigEntry,
) -> None:
"""Initialize the per-device coordinator."""
self.device = device
self.device_available = True
self._consecutive_failures = 0
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_{device.name}",
update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL),
always_update=True,
request_refresh_debouncer=Debouncer(
hass,
_LOGGER,
cooldown=REQUEST_REFRESH_DELAY,
immediate=False,
),
)
@property
def extra_attributes(self) -> dict[str, Any]:
"""Return extra device attributes."""
data: dict[str, Any] = {
"device_id": self.device.device_id,
"serial": self.device.serial,
"mac": self.device.mac,
}
if (unit_infos := self.device.units) is not None:
for i, unit in enumerate(unit_infos[:2]):
data[f"unit_{i}_model"] = unit.get("model")
data[f"unit_{i}_serial"] = unit.get("serial")
return data
@property
def device_id(self) -> str:
"""Return device ID."""
return self.device.device_id
@property
def building_id(self) -> str:
"""Return building ID of the device."""
return self.device.building_id
@property
def device_info(self) -> DeviceInfo:
"""Return a device description for device registry."""
model = None
if (unit_infos := self.device.units) is not None:
model = ", ".join([x["model"] for x in unit_infos if x["model"]])
return DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, self.device.mac)},
identifiers={(DOMAIN, f"{self.device.mac}-{self.device.serial}")},
manufacturer="Mitsubishi Electric",
model=model,
name=self.device.name,
)
def zone_device_info(self, zone: Zone) -> DeviceInfo:
"""Return a zone device description for device registry."""
dev = self.device
return DeviceInfo(
identifiers={(DOMAIN, f"{dev.mac}-{dev.serial}-{zone.zone_index}")},
manufacturer="Mitsubishi Electric",
model="ATW zone device",
name=f"{self.device.name} {zone.name}",
via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"),
)
async def _async_update_data(self) -> None:
"""Fetch data for this specific device from MELCloud."""
try:
await self.device.update()
# Success - reset failure counter and restore normal interval
if self._consecutive_failures > 0:
_LOGGER.info(
"Connection restored for %s after %d failed attempt(s)",
self.device.name,
self._consecutive_failures,
)
self._consecutive_failures = 0
self.update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
self.device_available = True
except ClientResponseError as ex:
if ex.status in (401, 403):
raise ConfigEntryAuthFailed from ex
if ex.status == 429:
_LOGGER.error(
"MELCloud rate limit exceeded for %s. Your account may be "
"temporarily blocked",
self.device.name,
)
# Rate limit - mark unavailable immediately
self.device_available = False
raise UpdateFailed(
f"Rate limit exceeded for {self.device.name}"
) from ex
# Other HTTP errors - use retry logic
self._handle_failure(f"Error updating {self.device.name}: {ex}", ex)
except ClientConnectionError as ex:
self._handle_failure(f"Connection failed for {self.device.name}: {ex}", ex)
def _handle_failure(self, message: str, exception: Exception | None = None) -> None:
"""Handle a connection failure with retry logic.
For transient failures, entities remain available with their last known
values for up to MAX_CONSECUTIVE_FAILURES attempts. During retries, the
update interval is shortened to RETRY_INTERVAL_SECONDS for faster recovery.
After the threshold is reached, entities are marked unavailable.
"""
self._consecutive_failures += 1
if self._consecutive_failures < MAX_CONSECUTIVE_FAILURES:
# Keep entities available with cached data, use shorter retry interval
_LOGGER.warning(
"%s (attempt %d/%d, retrying in %ds)",
message,
self._consecutive_failures,
MAX_CONSECUTIVE_FAILURES,
RETRY_INTERVAL_SECONDS,
)
self.update_interval = timedelta(seconds=RETRY_INTERVAL_SECONDS)
else:
# Threshold reached - mark unavailable and restore normal interval
_LOGGER.warning(
"%s (attempt %d/%d, marking unavailable)",
message,
self._consecutive_failures,
MAX_CONSECUTIVE_FAILURES,
)
self.device_available = False
self.update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL)
raise UpdateFailed(message) from exception
async def async_set(self, properties: dict[str, Any]) -> None:
"""Write state changes to the MELCloud API."""
try:
await self.device.set(properties)
self.device_available = True
except ClientConnectionError:
_LOGGER.warning("Connection failed for %s", self.device.name)
self.device_available = False
await self.async_request_refresh()
type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDeviceUpdateCoordinator]]]

View File

@@ -9,7 +9,7 @@ from homeassistant.const import CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import MelCloudConfigEntry
from .coordinator import MelCloudConfigEntry
TO_REDACT = {
CONF_USERNAME,

View File

@@ -0,0 +1,18 @@
"""Base entity for MELCloud integration."""
from __future__ import annotations
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import MelCloudDeviceUpdateCoordinator
class MelCloudEntity(CoordinatorEntity[MelCloudDeviceUpdateCoordinator]):
"""Base class for MELCloud entities."""
_attr_has_entity_name = True
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.device_available

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/melcloud",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["pymelcloud"],
"loggers": ["melcloud"],
"requirements": ["python-melcloud==0.1.2"]
}

View File

@@ -19,7 +19,8 @@ from homeassistant.const import UnitOfEnergy, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudConfigEntry, MelCloudDevice
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
from .entity import MelCloudEntity
@dataclasses.dataclass(frozen=True, kw_only=True)
@@ -111,70 +112,67 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
_hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud device sensors based on config_entry."""
mel_devices = entry.runtime_data
coordinators = entry.runtime_data
entities: list[MelDeviceSensor] = [
MelDeviceSensor(mel_device, description)
MelDeviceSensor(coordinator, description)
for description in ATA_SENSORS
for mel_device in mel_devices[DEVICE_TYPE_ATA]
if description.enabled(mel_device)
for coordinator in coordinators.get(DEVICE_TYPE_ATA, [])
if description.enabled(coordinator)
] + [
MelDeviceSensor(mel_device, description)
MelDeviceSensor(coordinator, description)
for description in ATW_SENSORS
for mel_device in mel_devices[DEVICE_TYPE_ATW]
if description.enabled(mel_device)
for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
if description.enabled(coordinator)
]
entities.extend(
[
AtwZoneSensor(mel_device, zone, description)
for mel_device in mel_devices[DEVICE_TYPE_ATW]
for zone in mel_device.device.zones
AtwZoneSensor(coordinator, zone, description)
for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
for zone in coordinator.device.zones
for description in ATW_ZONE_SENSORS
if description.enabled(zone)
]
)
async_add_entities(entities, True)
async_add_entities(entities)
class MelDeviceSensor(SensorEntity):
class MelDeviceSensor(MelCloudEntity, SensorEntity):
"""Representation of a Sensor."""
entity_description: MelcloudSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
api: MelCloudDevice,
coordinator: MelCloudDeviceUpdateCoordinator,
description: MelcloudSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
self._api = api
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{description.key}"
self._attr_device_info = api.device_info
self._attr_unique_id = (
f"{coordinator.device.serial}-{coordinator.device.mac}-{description.key}"
)
self._attr_device_info = coordinator.device_info
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._api)
async def async_update(self) -> None:
"""Retrieve latest state."""
await self._api.async_update()
return self.entity_description.value_fn(self.coordinator)
class AtwZoneSensor(MelDeviceSensor):
"""Air-to-Air device sensor."""
"""Air-to-Water zone sensor."""
def __init__(
self,
api: MelCloudDevice,
coordinator: MelCloudDeviceUpdateCoordinator,
zone: Zone,
description: MelcloudSensorEntityDescription,
) -> None:
@@ -184,9 +182,9 @@ class AtwZoneSensor(MelDeviceSensor):
description,
key=f"{description.key}-zone-{zone.zone_index}",
)
super().__init__(api, description)
super().__init__(coordinator, description)
self._attr_device_info = api.zone_device_info(zone)
self._attr_device_info = coordinator.zone_device_info(zone)
self._zone = zone
@property

View File

@@ -43,6 +43,9 @@
},
"entity": {
"sensor": {
"energy_consumed": {
"name": "Energy consumed"
},
"flow_temperature": {
"name": "Flow temperature"
},

View File

@@ -21,27 +21,27 @@ from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudConfigEntry, MelCloudDevice
from .const import ATTR_STATUS
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
from .entity import MelCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
_hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
mel_devices = entry.runtime_data
coordinators = entry.runtime_data
async_add_entities(
[
AtwWaterHeater(mel_device, mel_device.device)
for mel_device in mel_devices[DEVICE_TYPE_ATW]
],
True,
AtwWaterHeater(coordinator, coordinator.device)
for coordinator in coordinators.get(DEVICE_TYPE_ATW, [])
]
)
class AtwWaterHeater(WaterHeaterEntity):
class AtwWaterHeater(MelCloudEntity, WaterHeaterEntity):
"""Air-to-Water water heater."""
_attr_supported_features = (
@@ -49,27 +49,26 @@ class AtwWaterHeater(WaterHeaterEntity):
| WaterHeaterEntityFeature.ON_OFF
| WaterHeaterEntityFeature.OPERATION_MODE
)
_attr_has_entity_name = True
_attr_name = None
def __init__(self, api: MelCloudDevice, device: AtwDevice) -> None:
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
device: AtwDevice,
) -> None:
"""Initialize water heater device."""
self._api = api
super().__init__(coordinator)
self._device = device
self._attr_unique_id = api.device.serial
self._attr_device_info = api.device_info
self._attr_unique_id = coordinator.device.serial
self._attr_device_info = coordinator.device_info
async def async_update(self) -> None:
"""Update state from MELCloud."""
await self._api.async_update()
async def async_turn_on(self, **kwargs: Any) -> None:
async def async_turn_on(self, **_kwargs: Any) -> None:
"""Turn the entity on."""
await self._device.set({PROPERTY_POWER: True})
await self.coordinator.async_set({PROPERTY_POWER: True})
async def async_turn_off(self, **kwargs: Any) -> None:
async def async_turn_off(self, **_kwargs: Any) -> None:
"""Turn the entity off."""
await self._device.set({PROPERTY_POWER: False})
await self.coordinator.async_set({PROPERTY_POWER: False})
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
@@ -103,7 +102,7 @@ class AtwWaterHeater(WaterHeaterEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
await self._device.set(
await self.coordinator.async_set(
{
PROPERTY_TARGET_TANK_TEMPERATURE: kwargs.get(
"temperature", self.target_temperature
@@ -113,7 +112,7 @@ class AtwWaterHeater(WaterHeaterEntity):
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set new target operation mode."""
await self._device.set({PROPERTY_OPERATION_MODE: operation_mode})
await self.coordinator.async_set({PROPERTY_OPERATION_MODE: operation_mode})
@property
def min_temp(self) -> float:

View File

@@ -7,6 +7,7 @@ from mill_local import OperationMode
import voluptuous as vol
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
@@ -111,13 +112,16 @@ class MillHeater(MillBaseEntity, ClimateEntity):
super().__init__(coordinator, device)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
"""Set new target temperature and optionally HVAC mode."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_heater_temp(
self._id, float(temperature)
)
await self.coordinator.async_request_refresh()
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_handle_set_hvac_mode_service(hvac_mode)
else:
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -125,12 +129,11 @@ class MillHeater(MillBaseEntity, ClimateEntity):
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=True
)
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.heater_control(
self._id, power_status=False
)
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
@callback
def _update_attr(self, device: mill.Heater) -> None:
@@ -189,25 +192,26 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit
self._update_attr()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
"""Set new target temperature and optionally HVAC mode."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
await self.coordinator.mill_data_connection.set_target_temperature(
float(temperature)
)
await self.coordinator.async_request_refresh()
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_handle_set_hvac_mode_service(hvac_mode)
else:
await self.coordinator.async_request_refresh()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
if hvac_mode == HVACMode.HEAT:
await self.coordinator.mill_data_connection.set_operation_mode_control_individually()
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.OFF:
await self.coordinator.mill_data_connection.set_operation_mode_off()
await self.coordinator.async_request_refresh()
elif hvac_mode == HVACMode.AUTO:
await self.coordinator.mill_data_connection.set_operation_mode_weekly_program()
await self.coordinator.async_request_refresh()
await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None:

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from mycroftapi import MycroftAPI
@@ -10,6 +11,8 @@ from homeassistant.components.notify import BaseNotificationService
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -19,17 +22,17 @@ def get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> MycroftNotificationService:
"""Get the Mycroft notification service."""
return MycroftNotificationService(hass.data["mycroft"])
return MycroftNotificationService(hass.data[DOMAIN])
class MycroftNotificationService(BaseNotificationService):
"""The Mycroft Notification Service."""
def __init__(self, mycroft_ip):
def __init__(self, mycroft_ip: str) -> None:
"""Initialize the service."""
self.mycroft_ip = mycroft_ip
def send_message(self, message="", **kwargs):
def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message mycroft to speak on instance."""
text = message
@@ -37,4 +40,4 @@ class MycroftNotificationService(BaseNotificationService):
if mycroft is not None:
mycroft.speak_text(text)
else:
_LOGGER.log("Could not reach this instance of mycroft")
_LOGGER.warning("Could not reach this instance of mycroft")

View File

@@ -3,23 +3,25 @@
from datetime import timedelta
import logging
import defusedxml.ElementTree as ET
from aiohttp import ClientError, ClientSession
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, UPDATE_URL
_LOGGER = logging.getLogger(__name__)
DOMAIN = "namecheapdns"
INTERVAL = timedelta(minutes=5)
UPDATE_URL = "https://dynamicdns.park-your-domain.com/update"
CONFIG_SCHEMA = vol.Schema(
{
@@ -34,39 +36,74 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
type NamecheapConfigEntry = ConfigEntry[None]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the namecheap DNS component."""
host = config[DOMAIN][CONF_HOST]
domain = config[DOMAIN][CONF_DOMAIN]
password = config[DOMAIN][CONF_PASSWORD]
if DOMAIN in config:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
"""Set up Namecheap DynamicDNS from a config entry."""
host = entry.data[CONF_HOST]
domain = entry.data[CONF_DOMAIN]
password = entry.data[CONF_PASSWORD]
session = async_get_clientsession(hass)
result = await _update_namecheapdns(session, host, domain, password)
if not result:
return False
try:
if not await update_namecheapdns(session, host, domain, password):
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
},
)
except ClientError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
CONF_DOMAIN: f"{entry.data[CONF_HOST]}.{entry.data[CONF_DOMAIN]}"
},
) from e
async def update_domain_interval(now):
"""Update the namecheap DNS entry."""
await _update_namecheapdns(session, host, domain, password)
await update_namecheapdns(session, host, domain, password)
async_track_time_interval(hass, update_domain_interval, INTERVAL)
entry.async_on_unload(
async_track_time_interval(hass, update_domain_interval, INTERVAL)
)
return result
return True
async def _update_namecheapdns(session, host, domain, password):
async def async_unload_entry(hass: HomeAssistant, entry: NamecheapConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def update_namecheapdns(
session: ClientSession, host: str, domain: str, password: str
):
"""Update namecheap DNS entry."""
params = {"host": host, "domain": domain, "password": password}
resp = await session.get(UPDATE_URL, params=params)
xml_string = await resp.text()
root = ET.fromstring(xml_string)
err_count = root.find("ErrCount").text
if int(err_count) != 0:
if "<ErrCount>0</ErrCount>" not in xml_string:
_LOGGER.warning("Updating namecheap domain failed: %s", domain)
return False

View File

@@ -0,0 +1,91 @@
"""Config flow for the Namecheap DynamicDNS integration."""
from __future__ import annotations
import logging
from typing import Any
from aiohttp import ClientError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from . import update_namecheapdns
from .const import DOMAIN
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default="@"): cv.string,
vol.Required(CONF_DOMAIN): cv.string,
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD, autocomplete="current-password"
)
),
}
)
class NamecheapDnsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Namecheap DynamicDNS."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{CONF_HOST: user_input[CONF_HOST], CONF_DOMAIN: user_input[CONF_DOMAIN]}
)
session = async_get_clientsession(self.hass)
try:
if not await update_namecheapdns(session, **user_input):
errors["base"] = "update_failed"
except ClientError:
_LOGGER.debug("Cannot connect", exc_info=True)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title=f"{user_input[CONF_HOST]}.{user_input[CONF_DOMAIN]}",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
description_placeholders={"account_panel": "https://ap.www.namecheap.com/"},
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
self._async_abort_entries_match(
{CONF_HOST: import_info[CONF_HOST], CONF_DOMAIN: import_info[CONF_DOMAIN]}
)
result = await self.async_step_user(import_info)
if errors := result.get("errors"):
deprecate_yaml_issue(self.hass, import_success=False)
return self.async_abort(reason=errors["base"])
deprecate_yaml_issue(self.hass, import_success=True)
return result

View File

@@ -0,0 +1,6 @@
"""Constants for the Namecheap DynamicDNS integration."""
DOMAIN = "namecheapdns"
UPDATE_URL = "https://dynamicdns.park-your-domain.com/update"

View File

@@ -0,0 +1,40 @@
"""Issues for Namecheap DynamicDNS integration."""
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
@callback
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
"""Deprecate yaml issue."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.8.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Namecheap DynamicDNS",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_error",
breaks_in_ha_version="2026.8.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_error",
translation_placeholders={
"url": f"/config/integrations/dashboard/add?domain={DOMAIN}"
},
)

View File

@@ -1,9 +1,10 @@
{
"domain": "namecheapdns",
"name": "Namecheap FreeDNS",
"codeowners": [],
"name": "Namecheap DynamicDNS",
"codeowners": ["@tr4nt0r"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/namecheapdns",
"integration_type": "service",
"iot_class": "cloud_push",
"quality_scale": "legacy",
"requirements": ["defusedxml==0.7.1"]
"requirements": []
}

View File

@@ -0,0 +1,41 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"update_failed": "Updating DNS failed"
},
"step": {
"user": {
"data": {
"domain": "[%key:common::config_flow::data::username%]",
"host": "[%key:common::config_flow::data::host%]",
"password": "Dynamic DNS password"
},
"data_description": {
"domain": "The domain to update ('example.com')",
"host": "The host to update ('home' for home.example.com). Use '@' to update the root domain",
"password": "Dynamic DNS password for the domain"
},
"description": "Enter your Namecheap DynamicDNS domain and password below to configure dynamic DNS updates. You can find the Dynamic DNS password in your [Namecheap account]({account_panel}) under Domain List > Manage > Advanced DNS > Dynamic DNS."
}
}
},
"exceptions": {
"connection_error": {
"message": "Updating Namecheap DynamicDNS domain {domain} failed due to a connection error"
},
"update_failed": {
"message": "Updating Namecheap DynamicDNS domain {domain} failed"
}
},
"issues": {
"deprecated_yaml_import_issue_error": {
"description": "Configuring Namecheap DynamicDNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Namecheap DynamicDNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "The Namecheap DynamicDNS YAML configuration import failed"
}
}
}

View File

@@ -21,6 +21,7 @@ from .nasweb_data import NASwebData
PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL,
Platform.CLIMATE,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@@ -0,0 +1,168 @@
"""Platform for NASweb thermostat."""
from __future__ import annotations
import time
from typing import Any
from webio_api import Thermostat as NASwebThermostat
from webio_api.const import KEY_THERMOSTAT
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
UnitOfTemperature,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.helpers.update_coordinator import (
BaseCoordinatorEntity,
BaseDataUpdateCoordinatorProtocol,
)
from . import NASwebConfigEntry
from .const import DOMAIN, STATUS_UPDATE_MAX_TIME_INTERVAL
CLIMATE_TRANSLATION_KEY = "thermostat"
async def async_setup_entry(
hass: HomeAssistant,
config: NASwebConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up Climate platform."""
coordinator = config.runtime_data
nasweb_thermostat: NASwebThermostat = coordinator.data[KEY_THERMOSTAT]
climate = Thermostat(coordinator, nasweb_thermostat)
async_add_entities([climate])
class Thermostat(ClimateEntity, BaseCoordinatorEntity):
"""Entity representing NASweb thermostat."""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_has_entity_name = True
_attr_hvac_modes = [
HVACMode.OFF,
HVACMode.HEAT,
HVACMode.COOL,
HVACMode.HEAT_COOL,
HVACMode.FAN_ONLY,
]
_attr_max_temp = 50
_attr_min_temp = -50
_attr_precision = 1.0
_attr_should_poll = False
_attr_supported_features = ClimateEntityFeature(
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
_attr_target_temperature_step = 1.0
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = CLIMATE_TRANSLATION_KEY
def __init__(
self,
coordinator: BaseDataUpdateCoordinatorProtocol,
nasweb_thermostat: NASwebThermostat,
) -> None:
"""Initialize Thermostat."""
super().__init__(coordinator)
self._thermostat = nasweb_thermostat
self._attr_available = False
self._attr_name = nasweb_thermostat.name
self._attr_unique_id = f"{DOMAIN}.{self._thermostat.webio_serial}.thermostat"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._thermostat.webio_serial)}
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self._handle_coordinator_update()
def _set_attr_available(
self, entity_last_update: float, available: bool | None
) -> None:
if (
self.coordinator.last_update is None
or time.time() - entity_last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL
):
self._attr_available = False
else:
self._attr_available = available if available is not None else False
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_current_temperature = self._thermostat.current_temp
self._attr_target_temperature_low = self._thermostat.temp_target_min
self._attr_target_temperature_high = self._thermostat.temp_target_max
self._attr_hvac_mode = self._get_current_hvac_mode()
self._attr_hvac_action = self._get_current_action()
self._attr_name = self._thermostat.name if self._thermostat.name else None
self._set_attr_available(
self._thermostat.last_update, self._thermostat.available
)
self.async_write_ha_state()
def _get_current_hvac_mode(self) -> HVACMode:
have_cooling = self._thermostat.enabled_above_output
have_heating = self._thermostat.enabled_below_output
if have_cooling and have_heating:
return HVACMode.HEAT_COOL
if have_cooling:
return HVACMode.COOL
if have_heating:
return HVACMode.HEAT
if self._thermostat.enabled_inrange_output:
return HVACMode.FAN_ONLY
return HVACMode.OFF
def _get_current_action(self) -> HVACAction:
if self._thermostat.current_temp is None:
return HVACAction.OFF
if (
self._thermostat.temp_target_min is not None
and self._thermostat.current_temp < self._thermostat.temp_target_min
and self._thermostat.enabled_below_output
):
return HVACAction.HEATING
if (
self._thermostat.temp_target_max is not None
and self._thermostat.current_temp > self._thermostat.temp_target_max
and self._thermostat.enabled_above_output
):
return HVACAction.COOLING
if (
self._thermostat.temp_target_min is not None
and self._thermostat.temp_target_max is not None
and self._thermostat.current_temp >= self._thermostat.temp_target_min
and self._thermostat.current_temp <= self._thermostat.temp_target_max
and self._thermostat.enabled_inrange_output
):
return HVACAction.FAN
return HVACAction.IDLE
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
Scheduling updates is not necessary, the coordinator takes care of updates via push notifications.
"""
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVACMode for Thermostat."""
await self._thermostat.set_hvac_mode(hvac_mode)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set temperature range for Thermostat."""
await self._thermostat.set_temperature(
kwargs["target_temp_low"], kwargs["target_temp_high"]
)

View File

@@ -23,6 +23,7 @@ _LOGGER = logging.getLogger(__name__)
KEY_INPUTS = "inputs"
KEY_OUTPUTS = "outputs"
KEY_THERMOSTAT = "thermostat"
KEY_ZONES = "zones"
@@ -104,6 +105,7 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
KEY_THERMOSTAT: self.webio_api.thermostat,
KEY_ZONES: self.webio_api.zones,
}
self.async_set_updated_data(data)
@@ -199,6 +201,7 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
KEY_THERMOSTAT: self.webio_api.thermostat,
KEY_ZONES: self.webio_api.zones,
}
self.async_set_updated_data(new_data)

View File

@@ -29,6 +29,11 @@
"name": "Zone {index}"
}
},
"climate": {
"thermostat": {
"name": "[%key:component::climate::entity_component::_::name%]"
}
},
"sensor": {
"sensor_input": {
"name": "Input {index}",

View File

@@ -47,10 +47,8 @@ rules:
test-coverage:
status: todo
comment: |
Use load_json_object_fixture in tests
Patch the library instead of the HTTP requests
Create a shared fixture for the mock config entry
Use snapshots for binary sensor tests
Use init_integration in tests
Evaluate the need of test_config_entry_not_ready

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["pynintendoauth", "pynintendoparental"],
"quality_scale": "bronze",
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.0"]
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2"]
}

View File

@@ -28,7 +28,7 @@ DEVICE_SUPPORT = {
"3A": (),
"3B": (),
"42": (),
"7E": ("EDS0066", "EDS0068"),
"7E": ("EDS0065", "EDS0066", "EDS0068"),
"A6": (),
"EF": ("HB_HUB", "HB_MOISTURE_METER", "HobbyBoards_EF"),
}

View File

@@ -297,6 +297,20 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
# 7E sensors are special sensors by Embedded Data Systems
EDS_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
"EDS0065": (
OneWireSensorEntityDescription(
key="EDS0065/temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
OneWireSensorEntityDescription(
key="EDS0065/humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
),
"EDS0066": (
OneWireSensorEntityDescription(
key="EDS0066/temperature",

View File

@@ -4,27 +4,35 @@ from __future__ import annotations
from openevsehttp.__main__ import OpenEVSE
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.exceptions import ConfigEntryNotReady
type OpenEVSEConfigEntry = ConfigEntry[OpenEVSE]
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
"""Set up openevse from a config entry."""
"""Set up OpenEVSE from a config entry."""
charger = OpenEVSE(
entry.data[CONF_HOST],
entry.data.get(CONF_USERNAME),
entry.data.get(CONF_PASSWORD),
)
entry.runtime_data = OpenEVSE(entry.data[CONF_HOST])
try:
await entry.runtime_data.test_and_get()
await charger.test_and_get()
except TimeoutError as ex:
raise ConfigEntryError("Unable to connect to charger") from ex
raise ConfigEntryNotReady("Unable to connect to charger") from ex
coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR])

View File

@@ -3,14 +3,22 @@
from typing import Any
from openevsehttp.__main__ import OpenEVSE
from openevsehttp.exceptions import AuthenticationError, MissingSerial
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service_info import zeroconf
from .const import CONF_ID, CONF_SERIAL, DOMAIN
USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string})
AUTH_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string}
)
class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
"""OpenEVSE config flow."""
@@ -21,39 +29,49 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Set up the instance."""
self.discovery_info: dict[str, Any] = {}
self._host: str | None = None
async def check_status(self, host: str) -> tuple[bool, str | None]:
async def check_status(
self, host: str, user: str | None = None, password: str | None = None
) -> tuple[dict[str, str], str | None]:
"""Check if we can connect to the OpenEVSE charger."""
charger = OpenEVSE(host)
charger = OpenEVSE(host, user, password)
try:
result = await charger.test_and_get()
except TimeoutError:
return False, None
return True, result.get(CONF_SERIAL)
return {"base": "cannot_connect"}, None
except AuthenticationError:
return {"base": "invalid_auth"}, None
except MissingSerial:
return {}, None
return {}, result.get(CONF_SERIAL)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
errors, serial = await self.check_status(user_input[CONF_HOST])
if (result := await self.check_status(user_input[CONF_HOST]))[0]:
if (serial := result[1]) is not None:
if not errors:
if serial is not None:
await self.async_set_unique_id(serial, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"OpenEVSE {user_input[CONF_HOST]}",
data=user_input,
)
errors = {CONF_HOST: "cannot_connect"}
if errors["base"] == "invalid_auth":
self._host = user_input[CONF_HOST]
return await self.async_step_auth()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input),
errors=errors,
)
@@ -61,9 +79,10 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
self._async_abort_entries_match({CONF_HOST: data[CONF_HOST]})
errors, serial = await self.check_status(data[CONF_HOST])
if (result := await self.check_status(data[CONF_HOST]))[0]:
if (serial := result[1]) is not None:
if not errors:
if serial is not None:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
else:
@@ -92,17 +111,20 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
}
)
self.context.update({"title_placeholders": {"name": name}})
if not (await self.check_status(host))[0]:
return self.async_abort(reason="cannot_connect")
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
errors, _ = await self.check_status(self.discovery_info[CONF_HOST])
if errors:
if errors["base"] == "invalid_auth":
return await self.async_step_auth()
return self.async_abort(reason="unavailable_host")
if user_input is None:
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self.discovery_info[CONF_NAME]},
@@ -112,3 +134,36 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
title=self.discovery_info[CONF_NAME],
data={CONF_HOST: self.discovery_info[CONF_HOST]},
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the authentication step."""
errors: dict[str, str] = {}
if user_input is not None:
host = self._host or self.discovery_info[CONF_HOST]
errors, serial = await self.check_status(
host,
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
if not errors:
if self.unique_id is None and serial is not None:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"OpenEVSE {host}",
data={
CONF_HOST: host,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="auth",
data_schema=self.add_suggested_values_to_schema(AUTH_SCHEMA, user_input),
errors=errors,
)

View File

@@ -0,0 +1,51 @@
"""Data update coordinator for OpenEVSE."""
from __future__ import annotations
from datetime import timedelta
import logging
from openevsehttp.__main__ import OpenEVSE
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
type OpenEVSEConfigEntry = ConfigEntry[OpenEVSEDataUpdateCoordinator]
class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[None]):
"""Class to manage fetching OpenEVSE data."""
config_entry: OpenEVSEConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: OpenEVSEConfigEntry,
charger: OpenEVSE,
) -> None:
"""Initialize coordinator."""
self.charger = charger
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> None:
"""Fetch data from OpenEVSE charger."""
try:
await self.charger.update()
except TimeoutError as error:
raise UpdateFailed(
f"Timeout communicating with charger: {error}"
) from error

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from openevsehttp.__main__ import OpenEVSE
@@ -33,61 +35,82 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import ConfigEntry
from .const import DOMAIN, INTEGRATION_TITLE
from .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OpenEVSESensorDescription(SensorEntityDescription):
"""Describes an OpenEVSE sensor entity."""
value_fn: Callable[[OpenEVSE], str | float | None]
SENSOR_TYPES: tuple[OpenEVSESensorDescription, ...] = (
OpenEVSESensorDescription(
key="status",
translation_key="status",
value_fn=lambda ev: ev.status,
),
SensorEntityDescription(
OpenEVSESensorDescription(
key="charge_time",
translation_key="charge_time",
native_unit_of_measurement=UnitOfTime.MINUTES,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ev: ev.charge_time_elapsed,
),
SensorEntityDescription(
OpenEVSESensorDescription(
key="ambient_temp",
translation_key="ambient_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ev: ev.ambient_temperature,
),
SensorEntityDescription(
OpenEVSESensorDescription(
key="ir_temp",
translation_key="ir_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ev: ev.ir_temperature,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
OpenEVSESensorDescription(
key="rtc_temp",
translation_key="rtc_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ev: ev.rtc_temperature,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
OpenEVSESensorDescription(
key="usage_session",
translation_key="usage_session",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda ev: ev.usage_session,
),
SensorEntityDescription(
OpenEVSESensorDescription(
key="usage_total",
translation_key="usage_total",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda ev: ev.usage_total,
),
)
@@ -154,41 +177,34 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
entry: OpenEVSEConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
"""Set up OpenEVSE sensors based on config entry."""
coordinator = entry.runtime_data
identifier = entry.unique_id or entry.entry_id
async_add_entities(
(
OpenEVSESensor(
config_entry.runtime_data,
description,
config_entry.entry_id,
config_entry.unique_id,
)
for description in SENSOR_TYPES
),
True,
OpenEVSESensor(coordinator, description, identifier, entry.unique_id)
for description in SENSOR_TYPES
)
class OpenEVSESensor(SensorEntity):
class OpenEVSESensor(CoordinatorEntity[OpenEVSEDataUpdateCoordinator], SensorEntity):
"""Implementation of an OpenEVSE sensor."""
_attr_has_entity_name = True
entity_description: OpenEVSESensorDescription
def __init__(
self,
charger: OpenEVSE,
description: SensorEntityDescription,
entry_id: str,
coordinator: OpenEVSEDataUpdateCoordinator,
description: OpenEVSESensorDescription,
identifier: str,
unique_id: str | None,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self.charger = charger
identifier = unique_id or entry_id
self._attr_unique_id = f"{identifier}-{description.key}"
self._attr_device_info = DeviceInfo(
@@ -201,28 +217,7 @@ class OpenEVSESensor(SensorEntity):
}
self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id
async def async_update(self) -> None:
"""Get the monitored data from the charger."""
try:
await self.charger.update()
except TimeoutError:
_LOGGER.warning("Could not update status for %s", self.name)
return
sensor_type = self.entity_description.key
if sensor_type == "status":
self._attr_native_value = self.charger.status
elif sensor_type == "charge_time":
self._attr_native_value = self.charger.charge_time_elapsed / 60
elif sensor_type == "ambient_temp":
self._attr_native_value = self.charger.ambient_temperature
elif sensor_type == "ir_temp":
self._attr_native_value = self.charger.ir_temperature
elif sensor_type == "rtc_temp":
self._attr_native_value = self.charger.rtc_temperature
elif sensor_type == "usage_session":
self._attr_native_value = float(self.charger.usage_session) / 1000
elif sensor_type == "usage_total":
self._attr_native_value = float(self.charger.usage_total) / 1000
else:
self._attr_native_value = "Unknown"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.charger)

Some files were not shown because too many files have changed in this diff Show More