mirror of
https://github.com/home-assistant/core.git
synced 2026-01-14 03:27:32 +01:00
Compare commits
62 Commits
setpoint_c
...
simplify_t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0607627f8c | ||
|
|
76f9e09527 | ||
|
|
a636705588 | ||
|
|
f5d0f347fb | ||
|
|
89418f579f | ||
|
|
e390848477 | ||
|
|
6fc7a43193 | ||
|
|
5e7c3c6a47 | ||
|
|
003f53f109 | ||
|
|
2533e37464 | ||
|
|
fed4bdb09b | ||
|
|
17e8c09e2b | ||
|
|
7006efca92 | ||
|
|
96130c093b | ||
|
|
fb3ee34c81 | ||
|
|
cb99400128 | ||
|
|
58ef925a07 | ||
|
|
41bbfb8725 | ||
|
|
ed226e31b1 | ||
|
|
e900bb9770 | ||
|
|
d173d25072 | ||
|
|
0959896984 | ||
|
|
4a3ae454b8 | ||
|
|
f2cf6b69bf | ||
|
|
176f847ebb | ||
|
|
277419aafb | ||
|
|
d2b8d165d7 | ||
|
|
bf74e67700 | ||
|
|
5c3b85a37a | ||
|
|
8543f3f989 | ||
|
|
52a8a66a91 | ||
|
|
002a931e70 | ||
|
|
0667bfc81d | ||
|
|
329b2c840d | ||
|
|
ea7e94bcc1 | ||
|
|
cc30add73a | ||
|
|
21cfb9a0e5 | ||
|
|
143eadd887 | ||
|
|
855da1d070 | ||
|
|
d5be76d7e6 | ||
|
|
5f396332df | ||
|
|
56e638e170 | ||
|
|
52b90c7706 | ||
|
|
a6221d16b6 | ||
|
|
51701cab7c | ||
|
|
010e1f2d0d | ||
|
|
66909fc9ca | ||
|
|
90a28c95c8 | ||
|
|
83f2c53e8c | ||
|
|
04b8e3d62e | ||
|
|
14b31ba458 | ||
|
|
3d70727e34 | ||
|
|
a84eb61484 | ||
|
|
1cb6510b49 | ||
|
|
e6a72b9c85 | ||
|
|
dcb12b0779 | ||
|
|
dc6dda20d8 | ||
|
|
f59a777855 | ||
|
|
828f409ab9 | ||
|
|
53cf731578 | ||
|
|
aa0c5dcc01 | ||
|
|
3914d196db |
@@ -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,
|
||||
|
||||
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@@ -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/`
|
||||
|
||||
179
.github/workflows/ci.yaml
vendored
179
.github/workflows/ci.yaml
vendored
@@ -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'
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
4
.vscode/settings.default.jsonc
vendored
4
.vscode/settings.default.jsonc
vendored
@@ -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
6
.vscode/tasks.json
vendored
@@ -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
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["pysilero-vad==3.2.0", "pyspeex-noise==1.0.2"]
|
||||
"requirements": ["pymicro-vad==1.0.1", "pyspeex-noise==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fressnapftracker==0.2.0"]
|
||||
"requirements": ["fressnapftracker==0.2.1"]
|
||||
}
|
||||
|
||||
@@ -20,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
|
||||
@@ -104,6 +107,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
|
||||
# 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
93
homeassistant/components/hikvision/camera.py
Normal file
93
homeassistant/components/hikvision/camera.py
Normal 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)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
193
homeassistant/components/melcloud/coordinator.py
Normal file
193
homeassistant/components/melcloud/coordinator.py
Normal 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]]]
|
||||
@@ -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,
|
||||
|
||||
18
homeassistant/components/melcloud/entity.py
Normal file
18
homeassistant/components/melcloud/entity.py
Normal 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
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"energy_consumed": {
|
||||
"name": "Energy consumed"
|
||||
},
|
||||
"flow_temperature": {
|
||||
"name": "Flow temperature"
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
91
homeassistant/components/namecheapdns/config_flow.py
Normal file
91
homeassistant/components/namecheapdns/config_flow.py
Normal 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
|
||||
6
homeassistant/components/namecheapdns/const.py
Normal file
6
homeassistant/components/namecheapdns/const.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Constants for the Namecheap DynamicDNS integration."""
|
||||
|
||||
DOMAIN = "namecheapdns"
|
||||
|
||||
|
||||
UPDATE_URL = "https://dynamicdns.park-your-domain.com/update"
|
||||
40
homeassistant/components/namecheapdns/issue.py
Normal file
40
homeassistant/components/namecheapdns/issue.py
Normal 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}"
|
||||
},
|
||||
)
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"domain": "namecheapdns",
|
||||
"name": "Namecheap DynamicDNS",
|
||||
"codeowners": [],
|
||||
"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": []
|
||||
}
|
||||
|
||||
41
homeassistant/components/namecheapdns/strings.json
Normal file
41
homeassistant/components/namecheapdns/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ from .nasweb_data import NASwebData
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.CLIMATE,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
168
homeassistant/components/nasweb/climate.py
Normal file
168
homeassistant/components/nasweb/climate.py
Normal 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"]
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"name": "Zone {index}"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"thermostat": {
|
||||
"name": "[%key:component::climate::entity_component::_::name%]"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"sensor_input": {
|
||||
"name": "Input {index}",
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
51
homeassistant/components/openevse/coordinator.py
Normal file
51
homeassistant/components/openevse/coordinator.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -5,9 +5,20 @@
|
||||
"unavailable_host": "Unable to connect to host"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Unable to connect"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"step": {
|
||||
"auth": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The password to access your OpenEVSE charger",
|
||||
"username": "The username to access your OpenEVSE charger"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.16.1"]
|
||||
"requirements": ["opower==0.16.2"]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["python-pooldose==0.8.1"]
|
||||
"requirements": ["python-pooldose==0.8.2"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import base64
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
@@ -65,26 +66,23 @@ def get_service(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> PushsaferNotificationService:
|
||||
"""Get the Pushsafer.com notification service."""
|
||||
return PushsaferNotificationService(
|
||||
config.get(CONF_DEVICE_KEY), hass.config.is_allowed_path
|
||||
)
|
||||
return PushsaferNotificationService(config[CONF_DEVICE_KEY])
|
||||
|
||||
|
||||
class PushsaferNotificationService(BaseNotificationService):
|
||||
"""Implementation of the notification service for Pushsafer.com."""
|
||||
|
||||
def __init__(self, private_key, is_allowed_path):
|
||||
def __init__(self, private_key: str) -> None:
|
||||
"""Initialize the service."""
|
||||
self._private_key = private_key
|
||||
self.is_allowed_path = is_allowed_path
|
||||
|
||||
def send_message(self, message="", **kwargs):
|
||||
def send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to specified target."""
|
||||
if kwargs.get(ATTR_TARGET) is None:
|
||||
targets: list[str] | None
|
||||
if (targets := kwargs.get(ATTR_TARGET)) is None:
|
||||
targets = ["a"]
|
||||
_LOGGER.debug("No target specified. Sending push to all")
|
||||
else:
|
||||
targets = kwargs.get(ATTR_TARGET)
|
||||
_LOGGER.debug("%s target(s) specified", len(targets))
|
||||
|
||||
title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
|
||||
@@ -170,7 +168,7 @@ class PushsaferNotificationService(BaseNotificationService):
|
||||
try:
|
||||
if local_path is not None:
|
||||
_LOGGER.debug("Loading image from local path")
|
||||
if self.is_allowed_path(local_path):
|
||||
if self.hass.config.is_allowed_path(local_path):
|
||||
file_mimetype = mimetypes.guess_type(local_path)
|
||||
_LOGGER.debug("Detected mimetype %s", file_mimetype)
|
||||
with open(local_path, "rb") as binary_file:
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/qingping",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["qingping-ble==1.0.1"]
|
||||
"requirements": ["qingping-ble==1.1.0"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
import aiohttp
|
||||
from aiohttp.client_exceptions import ClientError, ClientResponseError
|
||||
import tibber
|
||||
from tibber import data_api as tibber_data_api
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
@@ -23,17 +22,11 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util, ssl as ssl_util
|
||||
|
||||
from .const import (
|
||||
AUTH_IMPLEMENTATION,
|
||||
CONF_LEGACY_ACCESS_TOKEN,
|
||||
DATA_HASS_CONFIG,
|
||||
DOMAIN,
|
||||
TibberConfigEntry,
|
||||
)
|
||||
from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEntry
|
||||
from .coordinator import TibberDataAPICoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
@@ -44,24 +37,23 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class TibberRuntimeData:
|
||||
"""Runtime data for Tibber API entries."""
|
||||
|
||||
tibber_connection: tibber.Tibber
|
||||
session: OAuth2Session
|
||||
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
|
||||
_client: tibber_data_api.TibberDataAPI | None = None
|
||||
_client: tibber.Tibber | None = None
|
||||
|
||||
async def async_get_client(
|
||||
self, hass: HomeAssistant
|
||||
) -> tibber_data_api.TibberDataAPI:
|
||||
"""Return an authenticated Tibber Data API client."""
|
||||
async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber:
|
||||
"""Return an authenticated Tibber client."""
|
||||
await self.session.async_ensure_token_valid()
|
||||
token = self.session.token
|
||||
access_token = token.get(CONF_ACCESS_TOKEN)
|
||||
if not access_token:
|
||||
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
|
||||
if self._client is None:
|
||||
self._client = tibber_data_api.TibberDataAPI(
|
||||
access_token,
|
||||
self._client = tibber.Tibber(
|
||||
access_token=access_token,
|
||||
websession=async_get_clientsession(hass),
|
||||
time_zone=dt_util.get_default_time_zone(),
|
||||
ssl=ssl_util.get_default_context(),
|
||||
)
|
||||
self._client.set_access_token(access_token)
|
||||
return self._client
|
||||
@@ -88,32 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
|
||||
translation_key="data_api_reauth_required",
|
||||
)
|
||||
|
||||
tibber_connection = tibber.Tibber(
|
||||
access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN],
|
||||
websession=async_get_clientsession(hass),
|
||||
time_zone=dt_util.get_default_time_zone(),
|
||||
ssl=ssl_util.get_default_context(),
|
||||
)
|
||||
|
||||
async def _close(event: Event) -> None:
|
||||
await tibber_connection.rt_disconnect()
|
||||
|
||||
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
|
||||
|
||||
try:
|
||||
await tibber_connection.update_info()
|
||||
except (
|
||||
TimeoutError,
|
||||
aiohttp.ClientError,
|
||||
tibber.RetryableHttpExceptionError,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady("Unable to connect") from err
|
||||
except tibber.InvalidLoginError as exp:
|
||||
_LOGGER.error("Failed to login. %s", exp)
|
||||
return False
|
||||
except tibber.FatalHttpExceptionError:
|
||||
return False
|
||||
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
@@ -135,10 +101,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
entry.runtime_data = TibberRuntimeData(
|
||||
tibber_connection=tibber_connection,
|
||||
session=session,
|
||||
)
|
||||
|
||||
tibber_connection = await entry.runtime_data.async_get_client(hass)
|
||||
|
||||
async def _close(event: Event) -> None:
|
||||
await tibber_connection.rt_disconnect()
|
||||
|
||||
entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close))
|
||||
|
||||
try:
|
||||
await tibber_connection.update_info()
|
||||
except (
|
||||
TimeoutError,
|
||||
aiohttp.ClientError,
|
||||
tibber.RetryableHttpExceptionError,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady("Unable to connect") from err
|
||||
except tibber.InvalidLoginError as err:
|
||||
raise ConfigEntryAuthFailed("Invalid login credentials") from err
|
||||
except tibber.FatalHttpExceptionError as err:
|
||||
raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err
|
||||
|
||||
coordinator = TibberDataAPICoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data.data_api_coordinator = coordinator
|
||||
@@ -154,5 +139,6 @@ async def async_unload_entry(
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
):
|
||||
await config_entry.runtime_data.tibber_connection.rt_disconnect()
|
||||
tibber_connection = await config_entry.runtime_data.async_get_client(hass)
|
||||
await tibber_connection.rt_disconnect()
|
||||
return unload_ok
|
||||
|
||||
123
homeassistant/components/tibber/binary_sensor.py
Normal file
123
homeassistant/components/tibber/binary_sensor.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Support for Tibber binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
import tibber
|
||||
from tibber.data_api import TibberDevice
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, TibberConfigEntry
|
||||
from .coordinator import TibberDataAPICoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class TibberBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Tibber binary sensor entity."""
|
||||
|
||||
is_on_fn: Callable[[str], bool | None]
|
||||
|
||||
|
||||
DATA_API_BINARY_SENSORS: tuple[TibberBinarySensorEntityDescription, ...] = (
|
||||
TibberBinarySensorEntityDescription(
|
||||
key="connector.status",
|
||||
device_class=BinarySensorDeviceClass.PLUG,
|
||||
is_on_fn={"connected": True, "disconnected": False}.get,
|
||||
),
|
||||
TibberBinarySensorEntityDescription(
|
||||
key="charging.status",
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
is_on_fn={"charging": True, "idle": False}.get,
|
||||
),
|
||||
TibberBinarySensorEntityDescription(
|
||||
key="onOff",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
is_on_fn={"on": True, "off": False}.get,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TibberConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Tibber binary sensors."""
|
||||
coordinator = entry.runtime_data.data_api_coordinator
|
||||
assert coordinator is not None
|
||||
|
||||
entities: list[TibberDataAPIBinarySensor] = []
|
||||
api_binary_sensors = {sensor.key: sensor for sensor in DATA_API_BINARY_SENSORS}
|
||||
|
||||
for device in coordinator.data.values():
|
||||
for sensor in device.sensors:
|
||||
description: TibberBinarySensorEntityDescription | None = (
|
||||
api_binary_sensors.get(sensor.id)
|
||||
)
|
||||
if description is None:
|
||||
continue
|
||||
entities.append(TibberDataAPIBinarySensor(coordinator, device, description))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class TibberDataAPIBinarySensor(
|
||||
CoordinatorEntity[TibberDataAPICoordinator], BinarySensorEntity
|
||||
):
|
||||
"""Representation of a Tibber Data API binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: TibberBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TibberDataAPICoordinator,
|
||||
device: TibberDevice,
|
||||
entity_description: TibberBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._device_id: str = device.id
|
||||
self.entity_description = entity_description
|
||||
|
||||
self._attr_unique_id = f"{device.id}_{entity_description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.external_id)},
|
||||
name=device.name,
|
||||
manufacturer=device.brand,
|
||||
model=device.model,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available and self._device_id in self.coordinator.sensors_by_device
|
||||
)
|
||||
|
||||
@property
|
||||
def device(self) -> dict[str, tibber.data_api.Sensor]:
|
||||
"""Return the device sensors."""
|
||||
return self.coordinator.sensors_by_device[self._device_id]
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the binary sensor."""
|
||||
return self.entity_description.is_on_fn(
|
||||
str(self.device[self.entity_description.key].value)
|
||||
)
|
||||
@@ -8,21 +8,16 @@ from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import tibber
|
||||
from tibber import data_api as tibber_data_api
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .const import CONF_LEGACY_ACCESS_TOKEN, DATA_API_DEFAULT_SCOPES, DOMAIN
|
||||
from .const import DATA_API_DEFAULT_SCOPES, DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema({vol.Required(CONF_LEGACY_ACCESS_TOKEN): str})
|
||||
ERR_TIMEOUT = "timeout"
|
||||
ERR_CLIENT = "cannot_connect"
|
||||
ERR_TOKEN = "invalid_access_token"
|
||||
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,8 +31,7 @@ class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self._access_token: str | None = None
|
||||
self._title = ""
|
||||
self._oauth_data: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
@@ -52,114 +46,70 @@ class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
|
||||
}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=SOURCE_USER,
|
||||
data_schema=data_schema,
|
||||
description_placeholders={"url": TOKEN_URL},
|
||||
errors={},
|
||||
)
|
||||
|
||||
self._access_token = user_input[CONF_LEGACY_ACCESS_TOKEN].replace(" ", "")
|
||||
tibber_connection = tibber.Tibber(
|
||||
access_token=self._access_token,
|
||||
websession=async_get_clientsession(self.hass),
|
||||
)
|
||||
self._title = tibber_connection.name or "Tibber"
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
await tibber_connection.update_info()
|
||||
except TimeoutError:
|
||||
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT
|
||||
except tibber.InvalidLoginError:
|
||||
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN
|
||||
except (
|
||||
aiohttp.ClientError,
|
||||
tibber.RetryableHttpExceptionError,
|
||||
tibber.FatalHttpExceptionError,
|
||||
):
|
||||
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT
|
||||
|
||||
if errors:
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=SOURCE_USER,
|
||||
data_schema=data_schema,
|
||||
description_placeholders={"url": TOKEN_URL},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
await self.async_set_unique_id(tibber_connection.user_id)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._abort_if_unique_id_mismatch(
|
||||
reason="wrong_account",
|
||||
description_placeholders={"title": reauth_entry.title},
|
||||
)
|
||||
else:
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return await self.async_step_pick_implementation()
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reauth flow."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
|
||||
self._title = reauth_entry.title
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication by reusing the user step."""
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
|
||||
self._title = reauth_entry.title
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
)
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Finalize the OAuth flow and create the config entry."""
|
||||
if self._access_token is None:
|
||||
return self.async_abort(reason="missing_configuration")
|
||||
self._oauth_data = data
|
||||
return await self._async_validate_and_create()
|
||||
|
||||
data[CONF_LEGACY_ACCESS_TOKEN] = self._access_token
|
||||
async def async_step_connection_error(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle connection error retry."""
|
||||
if user_input is not None:
|
||||
return await self._async_validate_and_create()
|
||||
return self.async_show_form(step_id="connection_error")
|
||||
|
||||
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
|
||||
data_api_client = tibber_data_api.TibberDataAPI(
|
||||
access_token,
|
||||
async def _async_validate_and_create(self) -> ConfigFlowResult:
|
||||
"""Validate the OAuth token and create the config entry."""
|
||||
assert self._oauth_data is not None
|
||||
access_token = self._oauth_data[CONF_TOKEN][CONF_ACCESS_TOKEN]
|
||||
tibber_connection = tibber.Tibber(
|
||||
access_token=access_token,
|
||||
websession=async_get_clientsession(self.hass),
|
||||
)
|
||||
|
||||
try:
|
||||
await data_api_client.get_userinfo()
|
||||
except (aiohttp.ClientError, TimeoutError):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
await tibber_connection.update_info()
|
||||
except TimeoutError:
|
||||
return await self.async_step_connection_error()
|
||||
except tibber.InvalidLoginError:
|
||||
return self.async_abort(reason=ERR_TOKEN)
|
||||
except (
|
||||
aiohttp.ClientError,
|
||||
tibber.RetryableHttpExceptionError,
|
||||
):
|
||||
return await self.async_step_connection_error()
|
||||
except tibber.FatalHttpExceptionError:
|
||||
return self.async_abort(reason=ERR_CLIENT)
|
||||
|
||||
await self.async_set_unique_id(tibber_connection.user_id)
|
||||
|
||||
title = tibber_connection.name or "Tibber"
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
self._abort_if_unique_id_mismatch(
|
||||
reason="wrong_account",
|
||||
description_placeholders={"title": reauth_entry.title},
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data=data,
|
||||
title=self._title,
|
||||
data=self._oauth_data,
|
||||
title=title,
|
||||
)
|
||||
|
||||
return self.async_create_entry(title=self._title, data=data)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=title, data=self._oauth_data)
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import TibberRuntimeData
|
||||
@@ -13,8 +12,6 @@ if TYPE_CHECKING:
|
||||
type TibberConfigEntry = ConfigEntry[TibberRuntimeData]
|
||||
|
||||
|
||||
CONF_LEGACY_ACCESS_TOKEN = CONF_ACCESS_TOKEN
|
||||
|
||||
AUTH_IMPLEMENTATION = "auth_implementation"
|
||||
DATA_HASS_CONFIG = "tibber_hass_config"
|
||||
DOMAIN = "tibber"
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, cast
|
||||
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
import tibber
|
||||
from tibber.data_api import TibberDataAPI, TibberDevice
|
||||
from tibber.data_api import TibberDevice
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.components.recorder.models import (
|
||||
@@ -230,28 +230,26 @@ class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
|
||||
return device_sensors.get(sensor_id)
|
||||
return None
|
||||
|
||||
async def _async_get_client(self) -> TibberDataAPI:
|
||||
"""Get the Tibber Data API client with error handling."""
|
||||
async def _async_get_client(self) -> tibber.Tibber:
|
||||
"""Get the Tibber client with error handling."""
|
||||
try:
|
||||
return await self._runtime_data.async_get_client(self.hass)
|
||||
except ConfigEntryAuthFailed:
|
||||
raise
|
||||
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
|
||||
raise UpdateFailed(
|
||||
f"Unable to create Tibber Data API client: {err}"
|
||||
) from err
|
||||
raise UpdateFailed(f"Unable to create Tibber client: {err}") from err
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Initial load of Tibber Data API devices."""
|
||||
client = await self._async_get_client()
|
||||
devices = await client.get_all_devices()
|
||||
devices = await client.data_api.get_all_devices()
|
||||
self._build_sensor_lookup(devices)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, TibberDevice]:
|
||||
"""Fetch the latest device capabilities from the Tibber Data API."""
|
||||
client = await self._async_get_client()
|
||||
try:
|
||||
devices: dict[str, TibberDevice] = await client.update_devices()
|
||||
devices: dict[str, TibberDevice] = await client.data_api.update_devices()
|
||||
except tibber.exceptions.RateLimitExceededError as err:
|
||||
raise UpdateFailed(
|
||||
f"Rate limit exceeded, retry after {err.retry_after} seconds",
|
||||
|
||||
@@ -15,6 +15,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
runtime = config_entry.runtime_data
|
||||
tibber_connection = await runtime.async_get_client(hass)
|
||||
result: dict[str, Any] = {
|
||||
"homes": [
|
||||
{
|
||||
@@ -24,7 +25,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"last_cons_data_timestamp": home.last_cons_data_timestamp,
|
||||
"country": home.country,
|
||||
}
|
||||
for home in runtime.tibber_connection.get_homes(only_active=False)
|
||||
for home in tibber_connection.get_homes(only_active=False)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tibber",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"requirements": ["pyTibber==0.34.4"]
|
||||
"requirements": ["pyTibber==0.34.5"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tibber
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_TITLE_DEFAULT,
|
||||
NotifyEntity,
|
||||
@@ -37,7 +39,9 @@ class TibberNotificationEntity(NotifyEntity):
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message to Tibber devices."""
|
||||
tibber_connection = self._entry.runtime_data.tibber_connection
|
||||
tibber_connection: tibber.Tibber = (
|
||||
await self._entry.runtime_data.async_get_client(self.hass)
|
||||
)
|
||||
try:
|
||||
await tibber_connection.send_notification(
|
||||
title or ATTR_TITLE_DEFAULT, message
|
||||
|
||||
@@ -34,7 +34,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -348,10 +348,9 @@ async def _async_setup_graphql_sensors(
|
||||
) -> None:
|
||||
"""Set up the Tibber sensor."""
|
||||
|
||||
tibber_connection = entry.runtime_data.tibber_connection
|
||||
tibber_connection = await entry.runtime_data.async_get_client(hass)
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
coordinator: TibberDataCoordinator | None = None
|
||||
entities: list[TibberSensor] = []
|
||||
@@ -391,25 +390,6 @@ async def _async_setup_graphql_sensors(
|
||||
).async_set_updated_data
|
||||
)
|
||||
|
||||
# migrate
|
||||
old_id = home.info["viewer"]["home"]["meteringPointData"]["consumptionEan"]
|
||||
if old_id is None:
|
||||
continue
|
||||
|
||||
# migrate to new device ids
|
||||
old_entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, old_id)
|
||||
if old_entity_id is not None:
|
||||
entity_registry.async_update_entity(
|
||||
old_entity_id, new_unique_id=home.home_id
|
||||
)
|
||||
|
||||
# migrate to new device ids
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, old_id)})
|
||||
if device_entry and entry.entry_id in device_entry.config_entries:
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, new_identifiers={(DOMAIN, home.home_id)}
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -430,9 +410,6 @@ def _setup_data_api_sensors(
|
||||
for sensor in device.sensors:
|
||||
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
|
||||
if description is None:
|
||||
_LOGGER.debug(
|
||||
"Sensor %s not found in DATA_API_SENSORS, skipping", sensor
|
||||
)
|
||||
continue
|
||||
entities.append(TibberDataAPISensor(coordinator, device, description))
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -42,7 +42,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_config_entry",
|
||||
)
|
||||
tibber_connection = entries[0].runtime_data.tibber_connection
|
||||
tibber_connection = await entries[0].runtime_data.async_get_client(call.hass)
|
||||
|
||||
start = __get_date(call.data.get(ATTR_START), "start")
|
||||
end = __get_date(call.data.get(ATTR_END), "end")
|
||||
|
||||
@@ -2,26 +2,21 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
|
||||
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"connection_error": {
|
||||
"description": "Could not connect to Tibber. Check your internet connection and try again.",
|
||||
"title": "Connection failed"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "Reconnect your Tibber account to refresh access.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"access_token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"description": "Enter your access token from {url}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ from .models import (
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
)
|
||||
from .type_information import EnumTypeInformation
|
||||
|
||||
TUYA_HVAC_TO_HA = {
|
||||
"auto": HVACMode.HEAT_COOL,
|
||||
@@ -139,6 +140,58 @@ class _SwingModeWrapper(DeviceWrapper):
|
||||
return commands
|
||||
|
||||
|
||||
class _HvacModeWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for managing climate HVACMode."""
|
||||
|
||||
# Modes that do not map to HVAC modes are ignored (they are handled by PresetWrapper)
|
||||
|
||||
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
|
||||
"""Init _HvacModeWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = [
|
||||
TUYA_HVAC_TO_HA[tuya_mode]
|
||||
for tuya_mode in type_information.range
|
||||
if tuya_mode in TUYA_HVAC_TO_HA
|
||||
]
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> HVACMode | None:
|
||||
"""Read the device status."""
|
||||
if (raw := super().read_device_status(device)) not in TUYA_HVAC_TO_HA:
|
||||
return None
|
||||
return TUYA_HVAC_TO_HA[raw]
|
||||
|
||||
def _convert_value_to_raw_value(
|
||||
self, device: CustomerDevice, value: HVACMode
|
||||
) -> Any:
|
||||
"""Convert value to raw value."""
|
||||
return next(
|
||||
tuya_mode
|
||||
for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items()
|
||||
if ha_mode == value
|
||||
)
|
||||
|
||||
|
||||
class _PresetWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for managing climate preset modes."""
|
||||
|
||||
# Modes that map to HVAC modes are ignored (they are handled by HVACModeWrapper)
|
||||
|
||||
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
|
||||
"""Init _PresetWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.options = [
|
||||
tuya_mode
|
||||
for tuya_mode in type_information.range
|
||||
if tuya_mode not in TUYA_HVAC_TO_HA
|
||||
]
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> str | None:
|
||||
"""Read the device status."""
|
||||
if (raw := super().read_device_status(device)) in TUYA_HVAC_TO_HA:
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class TuyaClimateEntityDescription(ClimateEntityDescription):
|
||||
"""Describe an Tuya climate entity."""
|
||||
@@ -296,7 +349,10 @@ async def async_setup_entry(
|
||||
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
|
||||
prefer_function=True,
|
||||
),
|
||||
hvac_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
hvac_mode_wrapper=_HvacModeWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
preset_wrapper=_PresetWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
set_temperature_wrapper=temperature_wrappers[1],
|
||||
@@ -322,7 +378,6 @@ async def async_setup_entry(
|
||||
class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
"""Tuya Climate Device."""
|
||||
|
||||
_hvac_to_tuya: dict[str, str]
|
||||
entity_description: TuyaClimateEntityDescription
|
||||
_attr_name = None
|
||||
|
||||
@@ -335,7 +390,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
current_humidity_wrapper: DeviceWrapper[int] | None,
|
||||
current_temperature_wrapper: DeviceWrapper[float] | None,
|
||||
fan_mode_wrapper: DeviceWrapper[str] | None,
|
||||
hvac_mode_wrapper: DeviceWrapper[str] | None,
|
||||
hvac_mode_wrapper: DeviceWrapper[HVACMode] | None,
|
||||
preset_wrapper: DeviceWrapper[str] | None,
|
||||
set_temperature_wrapper: DeviceWrapper[float] | None,
|
||||
swing_wrapper: DeviceWrapper[str] | None,
|
||||
switch_wrapper: DeviceWrapper[bool] | None,
|
||||
@@ -351,6 +407,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
self._current_temperature = current_temperature_wrapper
|
||||
self._fan_mode_wrapper = fan_mode_wrapper
|
||||
self._hvac_mode_wrapper = hvac_mode_wrapper
|
||||
self._preset_wrapper = preset_wrapper
|
||||
self._set_temperature = set_temperature_wrapper
|
||||
self._swing_wrapper = swing_wrapper
|
||||
self._switch_wrapper = switch_wrapper
|
||||
@@ -366,29 +423,24 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
self._attr_target_temperature_step = set_temperature_wrapper.value_step
|
||||
|
||||
# Determine HVAC modes
|
||||
self._attr_hvac_modes: list[HVACMode] = []
|
||||
self._hvac_to_tuya = {}
|
||||
self._attr_hvac_modes = []
|
||||
if hvac_mode_wrapper:
|
||||
self._attr_hvac_modes = [HVACMode.OFF]
|
||||
unknown_hvac_modes: list[str] = []
|
||||
for tuya_mode in hvac_mode_wrapper.options:
|
||||
if tuya_mode in TUYA_HVAC_TO_HA:
|
||||
ha_mode = TUYA_HVAC_TO_HA[tuya_mode]
|
||||
self._hvac_to_tuya[ha_mode] = tuya_mode
|
||||
self._attr_hvac_modes.append(ha_mode)
|
||||
else:
|
||||
unknown_hvac_modes.append(tuya_mode)
|
||||
for mode in hvac_mode_wrapper.options:
|
||||
self._attr_hvac_modes.append(HVACMode(mode))
|
||||
|
||||
if unknown_hvac_modes: # Tuya modes are presets instead of hvac_modes
|
||||
self._attr_hvac_modes.append(description.switch_only_hvac_mode)
|
||||
self._attr_preset_modes = unknown_hvac_modes
|
||||
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
|
||||
elif switch_wrapper:
|
||||
self._attr_hvac_modes = [
|
||||
HVACMode.OFF,
|
||||
description.switch_only_hvac_mode,
|
||||
]
|
||||
|
||||
# Determine preset modes (ignore if empty options)
|
||||
if preset_wrapper and preset_wrapper.options:
|
||||
self._attr_hvac_modes.append(description.switch_only_hvac_mode)
|
||||
self._attr_preset_modes = preset_wrapper.options
|
||||
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
|
||||
|
||||
# Determine dpcode to use for setting the humidity
|
||||
if target_humidity_wrapper:
|
||||
self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
|
||||
@@ -419,17 +471,15 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
self.device, hvac_mode != HVACMode.OFF
|
||||
)
|
||||
)
|
||||
if self._hvac_mode_wrapper and hvac_mode in self._hvac_to_tuya:
|
||||
if self._hvac_mode_wrapper and hvac_mode in self._hvac_mode_wrapper.options:
|
||||
commands.extend(
|
||||
self._hvac_mode_wrapper.get_update_commands(
|
||||
self.device, self._hvac_to_tuya[hvac_mode]
|
||||
)
|
||||
self._hvac_mode_wrapper.get_update_commands(self.device, hvac_mode)
|
||||
)
|
||||
await self._async_send_commands(commands)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target preset mode."""
|
||||
await self._async_send_wrapper_updates(self._hvac_mode_wrapper, preset_mode)
|
||||
await self._async_send_wrapper_updates(self._preset_wrapper, preset_mode)
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
@@ -484,21 +534,12 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
return None
|
||||
|
||||
# If we do have a mode wrapper, check if the mode maps to an HVAC mode.
|
||||
if (hvac_status := self._read_wrapper(self._hvac_mode_wrapper)) is None:
|
||||
return None
|
||||
return TUYA_HVAC_TO_HA.get(hvac_status)
|
||||
return self._read_wrapper(self._hvac_mode_wrapper)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return preset mode."""
|
||||
if self._hvac_mode_wrapper is None:
|
||||
return None
|
||||
|
||||
mode = self._read_wrapper(self._hvac_mode_wrapper)
|
||||
if mode in TUYA_HVAC_TO_HA:
|
||||
return None
|
||||
|
||||
return mode
|
||||
return self._read_wrapper(self._preset_wrapper)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect", "unifi_discovery"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"],
|
||||
"requirements": ["uiprotect==8.1.1", "unifi-discovery==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -10,7 +10,12 @@ from .const import _LOGGER, CONF_DEVICE_DETAILS, DEVICE_TYPE, DEVICE_URL
|
||||
from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
|
||||
from .utils import async_client_session
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR]
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.IMAGE,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool:
|
||||
|
||||
@@ -54,6 +54,7 @@ class UpdateCoordinatorDataType:
|
||||
|
||||
devices: dict[str, VodafoneStationDeviceInfo]
|
||||
sensors: dict[str, Any]
|
||||
wifi: dict[str, Any]
|
||||
|
||||
|
||||
class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
@@ -137,6 +138,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
await self.api.login()
|
||||
raw_data_devices = await self.api.get_devices_data()
|
||||
data_sensors = await self.api.get_sensor_data()
|
||||
data_wifi = await self.api.get_wifi_data()
|
||||
await self.api.logout()
|
||||
except exceptions.CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
@@ -178,7 +180,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
|
||||
self.previous_devices = current_devices
|
||||
|
||||
return UpdateCoordinatorDataType(data_devices, data_sensors)
|
||||
return UpdateCoordinatorDataType(data_devices, data_sensors, data_wifi)
|
||||
|
||||
@property
|
||||
def signal_device_new(self) -> str:
|
||||
|
||||
87
homeassistant/components/vodafone_station/image.py
Normal file
87
homeassistant/components/vodafone_station/image.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Vodafone Station image."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from typing import Final, cast
|
||||
|
||||
from aiovodafone.const import WIFI_DATA
|
||||
|
||||
from homeassistant.components.image import ImageEntity, ImageEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import _LOGGER
|
||||
from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
IMAGE_TYPES: Final = (
|
||||
ImageEntityDescription(
|
||||
key="guest",
|
||||
translation_key="guest",
|
||||
),
|
||||
ImageEntityDescription(
|
||||
key="guest_5g",
|
||||
translation_key="guest_5g",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: VodafoneConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Guest WiFi QR code for device."""
|
||||
_LOGGER.debug("Setting up Vodafone Station images")
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
wifi = coordinator.data.wifi
|
||||
|
||||
async_add_entities(
|
||||
VodafoneGuestWifiQRImage(hass, coordinator, image_desc)
|
||||
for image_desc in IMAGE_TYPES
|
||||
if image_desc.key in wifi[WIFI_DATA]
|
||||
and "qr_code" in wifi[WIFI_DATA][image_desc.key]
|
||||
)
|
||||
|
||||
|
||||
class VodafoneGuestWifiQRImage(
|
||||
CoordinatorEntity[VodafoneStationRouter],
|
||||
ImageEntity,
|
||||
):
|
||||
"""Implementation of the Guest wifi QR code image entity."""
|
||||
|
||||
_attr_content_type = "image/png"
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
coordinator: VodafoneStationRouter,
|
||||
description: ImageEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize QR code image entity."""
|
||||
super().__init__(coordinator)
|
||||
ImageEntity.__init__(self, hass)
|
||||
|
||||
self.entity_description = description
|
||||
self._attr_device_info = coordinator.device_info
|
||||
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}-qr-code"
|
||||
|
||||
async def async_image(self) -> bytes | None:
|
||||
"""Return QR code image bytes."""
|
||||
qr_code = cast(
|
||||
BytesIO,
|
||||
self.coordinator.data.wifi[WIFI_DATA][self.entity_description.key][
|
||||
"qr_code"
|
||||
],
|
||||
)
|
||||
return qr_code.getvalue()
|
||||
@@ -65,6 +65,14 @@
|
||||
"name": "Internet key reconnect"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
"guest": {
|
||||
"name": "Guest network"
|
||||
},
|
||||
"guest_5g": {
|
||||
"name": "Guest 5GHz network"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"active_connection": {
|
||||
"name": "Active connection",
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["voip_utils"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["voip-utils==0.3.4"]
|
||||
"requirements": ["voip-utils==0.3.5"]
|
||||
}
|
||||
|
||||
@@ -5,15 +5,14 @@ from __future__ import annotations
|
||||
import abc
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Container, Coroutine, Hashable, Iterable, Mapping
|
||||
from collections.abc import Callable, Container, Hashable, Iterable, Mapping
|
||||
from contextlib import suppress
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import functools
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Concatenate, Generic, Required, TypedDict, TypeVar, cast
|
||||
from typing import Any, Generic, Required, TypedDict, TypeVar, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -151,15 +150,6 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False):
|
||||
url: str
|
||||
|
||||
|
||||
class ProgressStepData[_FlowResultT](TypedDict):
|
||||
"""Typed data for progress step tracking."""
|
||||
|
||||
tasks: dict[str, asyncio.Task[Any]]
|
||||
abort_reason: str
|
||||
abort_description_placeholders: Mapping[str, str]
|
||||
next_step_result: _FlowResultT | None
|
||||
|
||||
|
||||
def _map_error_to_schema_errors(
|
||||
schema_errors: dict[str, Any],
|
||||
error: vol.Invalid,
|
||||
@@ -645,24 +635,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
__progress_task: asyncio.Task[Any] | None = None
|
||||
__no_progress_task_reported = False
|
||||
deprecated_show_progress = False
|
||||
__progress_step_data: ProgressStepData[_FlowResultT] | None = None
|
||||
|
||||
@property
|
||||
def _progress_step_data(self) -> ProgressStepData[_FlowResultT]:
|
||||
"""Return progress step data.
|
||||
|
||||
A property is used instead of a simple attribute as derived classes
|
||||
do not call super().__init__.
|
||||
The property makes sure that the dict is initialized if needed.
|
||||
"""
|
||||
if not self.__progress_step_data:
|
||||
self.__progress_step_data = {
|
||||
"tasks": {},
|
||||
"abort_reason": "",
|
||||
"abort_description_placeholders": MappingProxyType({}),
|
||||
"next_step_result": None,
|
||||
}
|
||||
return self.__progress_step_data
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
@@ -785,39 +757,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step__progress_step_abort(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> _FlowResultT:
|
||||
"""Abort the flow."""
|
||||
progress_step_data = self._progress_step_data
|
||||
return self.async_abort(
|
||||
reason=progress_step_data["abort_reason"],
|
||||
description_placeholders=progress_step_data[
|
||||
"abort_description_placeholders"
|
||||
],
|
||||
)
|
||||
|
||||
async def async_step__progress_step_progress_done(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> _FlowResultT:
|
||||
"""Progress done. Return the next step.
|
||||
|
||||
Used by the progress_step decorator
|
||||
to allow decorated step methods
|
||||
to call the next step method, to change step,
|
||||
without using async_show_progress_done.
|
||||
If no next step is set, abort the flow.
|
||||
"""
|
||||
progress_step_data = self._progress_step_data
|
||||
if (next_step_result := progress_step_data["next_step_result"]) is None:
|
||||
return self.async_abort(
|
||||
reason=progress_step_data["abort_reason"],
|
||||
description_placeholders=progress_step_data[
|
||||
"abort_description_placeholders"
|
||||
],
|
||||
)
|
||||
return next_step_result
|
||||
|
||||
@callback
|
||||
def async_external_step(
|
||||
self,
|
||||
@@ -998,90 +937,3 @@ class section:
|
||||
def __call__(self, value: Any) -> Any:
|
||||
"""Validate input."""
|
||||
return self.schema(value)
|
||||
|
||||
|
||||
type _FuncType[_T: FlowHandler[Any, Any, Any], _R: FlowResult[Any, Any], **_P] = (
|
||||
Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]
|
||||
)
|
||||
|
||||
|
||||
def progress_step[
|
||||
HandlerT: FlowHandler[Any, Any, Any],
|
||||
ResultT: FlowResult[Any, Any],
|
||||
**P,
|
||||
](
|
||||
description_placeholders: (
|
||||
dict[str, str] | Callable[[Any], dict[str, str]] | None
|
||||
) = None,
|
||||
) -> Callable[[_FuncType[HandlerT, ResultT, P]], _FuncType[HandlerT, ResultT, P]]:
|
||||
"""Decorator to create a progress step from an async function.
|
||||
|
||||
The decorated method should be a step method
|
||||
which needs to show progress.
|
||||
The method should accept dict[str, Any] as user_input
|
||||
and should return a FlowResult or raise AbortFlow.
|
||||
The method can call self.async_update_progress(progress)
|
||||
to update progress.
|
||||
|
||||
Args:
|
||||
description_placeholders: Static dict or callable that returns dict for progress UI placeholders.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: _FuncType[HandlerT, ResultT, P],
|
||||
) -> _FuncType[HandlerT, ResultT, P]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
self: FlowHandler[Any, ResultT], *args: P.args, **kwargs: P.kwargs
|
||||
) -> ResultT:
|
||||
step_id = func.__name__.replace("async_step_", "")
|
||||
progress_step_data = self._progress_step_data
|
||||
# Check if we have a progress task running
|
||||
progress_task = progress_step_data["tasks"].get(step_id)
|
||||
|
||||
if progress_task is None:
|
||||
# First call - create and start the progress task
|
||||
progress_task = self.hass.async_create_task(
|
||||
func(self, *args, **kwargs), # type: ignore[arg-type]
|
||||
f"Progress step {step_id}",
|
||||
)
|
||||
progress_step_data["tasks"][step_id] = progress_task
|
||||
|
||||
if not progress_task.done():
|
||||
# Handle description placeholders
|
||||
placeholders = None
|
||||
if description_placeholders is not None:
|
||||
if callable(description_placeholders):
|
||||
placeholders = description_placeholders(self)
|
||||
else:
|
||||
placeholders = description_placeholders
|
||||
|
||||
return self.async_show_progress(
|
||||
step_id=step_id,
|
||||
progress_action=step_id,
|
||||
progress_task=progress_task,
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
# Task is done or this is a subsequent call
|
||||
try:
|
||||
progress_step_data["next_step_result"] = await progress_task
|
||||
except AbortFlow as err:
|
||||
progress_step_data["abort_reason"] = err.reason
|
||||
progress_step_data["abort_description_placeholders"] = (
|
||||
err.description_placeholders or {}
|
||||
)
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="_progress_step_abort"
|
||||
)
|
||||
finally:
|
||||
# Clean up task reference
|
||||
progress_step_data["tasks"].pop(step_id, None)
|
||||
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="_progress_step_progress_done"
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -443,6 +443,7 @@ FLOWS = {
|
||||
"mystrom",
|
||||
"myuplink",
|
||||
"nam",
|
||||
"namecheapdns",
|
||||
"nanoleaf",
|
||||
"nasweb",
|
||||
"neato",
|
||||
|
||||
@@ -4343,8 +4343,8 @@
|
||||
},
|
||||
"namecheapdns": {
|
||||
"name": "Namecheap DynamicDNS",
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
"nanoleaf": {
|
||||
|
||||
@@ -53,10 +53,10 @@ Pillow==12.0.0
|
||||
propcache==0.4.1
|
||||
psutil-home-assistant==0.0.1
|
||||
PyJWT==2.10.1
|
||||
pymicro-vad==1.0.1
|
||||
PyNaCl==1.6.0
|
||||
pyOpenSSL==25.3.0
|
||||
pyserial==3.5
|
||||
pysilero-vad==3.2.0
|
||||
pyspeex-noise==1.0.2
|
||||
python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.0
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.const import Platform
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# InferenceResult is available only from astroid >= 2.12.0
|
||||
# pre-commit should still work on out of date environments
|
||||
# prek should still work on out of date environments
|
||||
from astroid.typing import InferenceResult
|
||||
|
||||
_COMMON_ARGUMENTS: dict[str, list[str]] = {
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -39,8 +39,8 @@ Pillow==12.0.0
|
||||
propcache==0.4.1
|
||||
psutil-home-assistant==0.0.1
|
||||
PyJWT==2.10.1
|
||||
pymicro-vad==1.0.1
|
||||
pyOpenSSL==25.3.0
|
||||
pysilero-vad==3.2.0
|
||||
pyspeex-noise==1.0.2
|
||||
python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.0
|
||||
|
||||
23
requirements_all.txt
generated
23
requirements_all.txt
generated
@@ -785,7 +785,6 @@ decora-wifi==1.4
|
||||
deebot-client==17.0.1
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
# homeassistant.components.ohmconnect
|
||||
# homeassistant.components.sonos
|
||||
defusedxml==0.7.1
|
||||
@@ -1011,7 +1010,7 @@ freebox-api==1.2.2
|
||||
freesms==0.2.0
|
||||
|
||||
# homeassistant.components.fressnapf_tracker
|
||||
fressnapftracker==0.2.0
|
||||
fressnapftracker==0.2.1
|
||||
|
||||
# homeassistant.components.fritz
|
||||
# homeassistant.components.fritzbox_callmonitor
|
||||
@@ -1684,7 +1683,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.1
|
||||
opower==0.16.2
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -1867,7 +1866,7 @@ pyRFXtrx==0.31.1
|
||||
pySDCP==1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.34.4
|
||||
pyTibber==0.34.5
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -2201,6 +2200,9 @@ pymediaroom==0.6.5.4
|
||||
# homeassistant.components.meteoclimatic
|
||||
pymeteoclimatic==0.1.0
|
||||
|
||||
# homeassistant.components.assist_pipeline
|
||||
pymicro-vad==1.0.1
|
||||
|
||||
# homeassistant.components.miele
|
||||
pymiele==0.6.1
|
||||
|
||||
@@ -2408,9 +2410,6 @@ pysiaalarm==3.1.1
|
||||
# homeassistant.components.signal_messenger
|
||||
pysignalclirestapi==0.3.24
|
||||
|
||||
# homeassistant.components.assist_pipeline
|
||||
pysilero-vad==3.2.0
|
||||
|
||||
# homeassistant.components.sky_hub
|
||||
pyskyqhub==0.1.4
|
||||
|
||||
@@ -2575,7 +2574,7 @@ python-overseerr==0.8.0
|
||||
python-picnic-api2==1.3.1
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.8.1
|
||||
python-pooldose==0.8.2
|
||||
|
||||
# homeassistant.components.rabbitair
|
||||
python-rabbitair==0.0.8
|
||||
@@ -2708,7 +2707,7 @@ qbittorrent-api==2024.9.67
|
||||
qbusmqttapi==1.4.2
|
||||
|
||||
# homeassistant.components.qingping
|
||||
qingping-ble==1.0.1
|
||||
qingping-ble==1.1.0
|
||||
|
||||
# homeassistant.components.qnap
|
||||
qnapstats==0.4.0
|
||||
@@ -3081,7 +3080,7 @@ typedmonarchmoney==0.4.4
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==8.0.0
|
||||
uiprotect==8.1.1
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -3146,7 +3145,7 @@ visionpluspython==1.0.2
|
||||
vobject==0.9.9
|
||||
|
||||
# homeassistant.components.voip
|
||||
voip-utils==0.3.4
|
||||
voip-utils==0.3.5
|
||||
|
||||
# homeassistant.components.volkszaehler
|
||||
volkszaehler==0.4.0
|
||||
@@ -3219,7 +3218,7 @@ wyoming==1.7.2
|
||||
xiaomi-ble==1.4.1
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.13.0
|
||||
xknx==3.14.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknxproject==3.8.2
|
||||
|
||||
@@ -15,7 +15,7 @@ librt==0.2.1
|
||||
license-expression==30.4.3
|
||||
mock-open==1.4.0
|
||||
mypy-dev==1.19.0a4
|
||||
pre-commit==4.2.0
|
||||
prek==0.2.28
|
||||
pydantic==2.12.2
|
||||
pylint==4.0.1
|
||||
pylint-per-file-ignores==1.4.0
|
||||
|
||||
23
requirements_test_all.txt
generated
23
requirements_test_all.txt
generated
@@ -694,7 +694,6 @@ debugpy==1.8.17
|
||||
deebot-client==17.0.1
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
# homeassistant.components.ohmconnect
|
||||
# homeassistant.components.sonos
|
||||
defusedxml==0.7.1
|
||||
@@ -890,7 +889,7 @@ forecast-solar==4.2.0
|
||||
freebox-api==1.2.2
|
||||
|
||||
# homeassistant.components.fressnapf_tracker
|
||||
fressnapftracker==0.2.0
|
||||
fressnapftracker==0.2.1
|
||||
|
||||
# homeassistant.components.fritz
|
||||
# homeassistant.components.fritzbox_callmonitor
|
||||
@@ -1458,7 +1457,7 @@ openrgb-python==0.3.6
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.1
|
||||
opower==0.16.2
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -1598,7 +1597,7 @@ pyHomee==1.3.8
|
||||
pyRFXtrx==0.31.1
|
||||
|
||||
# homeassistant.components.tibber
|
||||
pyTibber==0.34.4
|
||||
pyTibber==0.34.5
|
||||
|
||||
# homeassistant.components.dlink
|
||||
pyW215==0.8.0
|
||||
@@ -1863,6 +1862,9 @@ pymata-express==1.19
|
||||
# homeassistant.components.meteoclimatic
|
||||
pymeteoclimatic==0.1.0
|
||||
|
||||
# homeassistant.components.assist_pipeline
|
||||
pymicro-vad==1.0.1
|
||||
|
||||
# homeassistant.components.miele
|
||||
pymiele==0.6.1
|
||||
|
||||
@@ -2034,9 +2036,6 @@ pysiaalarm==3.1.1
|
||||
# homeassistant.components.signal_messenger
|
||||
pysignalclirestapi==0.3.24
|
||||
|
||||
# homeassistant.components.assist_pipeline
|
||||
pysilero-vad==3.2.0
|
||||
|
||||
# homeassistant.components.sma
|
||||
pysma==1.1.0
|
||||
|
||||
@@ -2165,7 +2164,7 @@ python-overseerr==0.8.0
|
||||
python-picnic-api2==1.3.1
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.8.1
|
||||
python-pooldose==0.8.2
|
||||
|
||||
# homeassistant.components.rabbitair
|
||||
python-rabbitair==0.0.8
|
||||
@@ -2277,7 +2276,7 @@ qbittorrent-api==2024.9.67
|
||||
qbusmqttapi==1.4.2
|
||||
|
||||
# homeassistant.components.qingping
|
||||
qingping-ble==1.0.1
|
||||
qingping-ble==1.1.0
|
||||
|
||||
# homeassistant.components.qnap
|
||||
qnapstats==0.4.0
|
||||
@@ -2575,7 +2574,7 @@ typedmonarchmoney==0.4.4
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==8.0.0
|
||||
uiprotect==8.1.1
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -2634,7 +2633,7 @@ visionpluspython==1.0.2
|
||||
vobject==0.9.9
|
||||
|
||||
# homeassistant.components.voip
|
||||
voip-utils==0.3.4
|
||||
voip-utils==0.3.5
|
||||
|
||||
# homeassistant.components.volvo
|
||||
volvocarsapi==0.4.3
|
||||
@@ -2692,7 +2691,7 @@ wyoming==1.7.2
|
||||
xiaomi-ble==1.4.1
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.13.0
|
||||
xknx==3.14.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknxproject==3.8.2
|
||||
|
||||
@@ -427,7 +427,7 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
if config.action == "generate" and manifests_resorted:
|
||||
subprocess.run(
|
||||
[
|
||||
"pre-commit",
|
||||
"prek",
|
||||
"run",
|
||||
"--hook-stage",
|
||||
"manual",
|
||||
|
||||
@@ -15,7 +15,7 @@ printf "%s\n" $files
|
||||
echo "=============="
|
||||
echo "LINT with ruff"
|
||||
echo "=============="
|
||||
pre-commit run ruff-check --files $files
|
||||
prek run ruff-check --files $files
|
||||
echo "================"
|
||||
echo "LINT with pylint"
|
||||
echo "================"
|
||||
|
||||
@@ -119,7 +119,7 @@ async def pylint(files):
|
||||
|
||||
async def ruff(files):
|
||||
"""Exec ruff."""
|
||||
_, log = await async_exec("pre-commit", "run", "ruff", "--files", *files)
|
||||
_, log = await async_exec("prek", "run", "ruff", "--files", *files)
|
||||
res = []
|
||||
for line in log.splitlines():
|
||||
line = line.split(":")
|
||||
|
||||
@@ -24,7 +24,12 @@ def gather_info(arguments) -> Info:
|
||||
info = _gather_info(
|
||||
{
|
||||
"domain": {
|
||||
"prompt": "What is the domain?",
|
||||
"prompt": (
|
||||
"""What is the domain?
|
||||
|
||||
Hint: The domain is a short name consisting of characters and underscores.
|
||||
This domain has to be unique, cannot be changed, and has to match the directory name of the integration."""
|
||||
),
|
||||
"validators": [
|
||||
CHECK_EMPTY,
|
||||
[
|
||||
@@ -72,13 +77,8 @@ def gather_new_integration(determine_auth: bool) -> Info:
|
||||
},
|
||||
"codeowner": {
|
||||
"prompt": "What is your GitHub handle?",
|
||||
"validators": [
|
||||
CHECK_EMPTY,
|
||||
[
|
||||
'GitHub handles need to start with an "@"',
|
||||
lambda value: value.startswith("@"),
|
||||
],
|
||||
],
|
||||
"validators": [CHECK_EMPTY],
|
||||
"converter": lambda value: value if value.startswith("@") else f"@{value}",
|
||||
},
|
||||
"requirement": {
|
||||
"prompt": "What PyPI package and version do you depend on? Leave blank for none.",
|
||||
|
||||
@@ -31,7 +31,7 @@ fi
|
||||
|
||||
script/bootstrap
|
||||
|
||||
pre-commit install
|
||||
prek install
|
||||
|
||||
hass --script ensure_config -c config
|
||||
|
||||
|
||||
@@ -2,15 +2,19 @@
|
||||
"""Helper script to bump the current version."""
|
||||
|
||||
import argparse
|
||||
from copy import replace
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import packaging
|
||||
from packaging.version import Version
|
||||
|
||||
from homeassistant import const
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
_PACKAGING_VERSION_BELOW_26 = Version(packaging.__version__) < Version("26.0dev0")
|
||||
|
||||
|
||||
def _bump_release(release, bump_type):
|
||||
"""Bump a release tuple consisting of 3 numbers."""
|
||||
@@ -25,6 +29,13 @@ def _bump_release(release, bump_type):
|
||||
return major, minor, patch
|
||||
|
||||
|
||||
def _get_dev_change(dev: int) -> int | tuple[str, int]:
|
||||
"""Return the dev change based on packaging version."""
|
||||
if _PACKAGING_VERSION_BELOW_26:
|
||||
return ("dev", dev)
|
||||
return dev
|
||||
|
||||
|
||||
def bump_version(
|
||||
version: Version, bump_type: str, *, nightly_version: str | None = None
|
||||
) -> Version:
|
||||
@@ -58,9 +69,10 @@ def bump_version(
|
||||
# Convert 0.67.3.b5 to 0.67.4.dev0
|
||||
# Convert 0.67.3.dev0 to 0.67.3.dev1
|
||||
if version.is_devrelease:
|
||||
to_change["dev"] = ("dev", version.dev + 1)
|
||||
to_change["dev"] = _get_dev_change(version.dev + 1)
|
||||
else:
|
||||
to_change["pre"] = ("dev", 0)
|
||||
to_change["dev"] = _get_dev_change(0)
|
||||
to_change["pre"] = None
|
||||
to_change["release"] = _bump_release(version.release, "minor")
|
||||
|
||||
elif bump_type == "beta":
|
||||
@@ -99,14 +111,19 @@ def bump_version(
|
||||
raise ValueError("Nightly version must be a dev version")
|
||||
new_dev = new_version.dev
|
||||
|
||||
to_change["dev"] = ("dev", new_dev)
|
||||
if not isinstance(new_dev, int):
|
||||
new_dev = int(new_dev)
|
||||
to_change["dev"] = _get_dev_change(new_dev)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported type: {bump_type}")
|
||||
|
||||
temp = Version("0")
|
||||
temp._version = version._version._replace(**to_change) # noqa: SLF001
|
||||
return Version(str(temp))
|
||||
if _PACKAGING_VERSION_BELOW_26:
|
||||
temp = Version("0")
|
||||
temp._version = version._version._replace(**to_change) # noqa: SLF001
|
||||
return Version(str(temp))
|
||||
|
||||
return replace(version, **to_change)
|
||||
|
||||
|
||||
def write_version(version):
|
||||
|
||||
@@ -172,6 +172,90 @@ class StateDescription(TypedDict):
|
||||
count: int
|
||||
|
||||
|
||||
class ConditionStateDescription(TypedDict):
|
||||
"""Test state and expected service call count."""
|
||||
|
||||
included: _StateDescription
|
||||
excluded: _StateDescription
|
||||
condition_true: bool
|
||||
state_valid: bool
|
||||
|
||||
|
||||
def parametrize_condition_states(
|
||||
*,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any] | None = None,
|
||||
target_states: list[str | None | tuple[str | None, dict]],
|
||||
other_states: list[str | None | tuple[str | None, dict]],
|
||||
additional_attributes: dict | None = None,
|
||||
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
|
||||
"""Parametrize states and expected service call counts.
|
||||
|
||||
The target_states and other_states iterables are either iterables of
|
||||
states or iterables of (state, attributes) tuples.
|
||||
|
||||
Returns a list of tuples with (condition, condition options, list of states),
|
||||
where states is a list of ConditionStateDescription dicts.
|
||||
"""
|
||||
|
||||
additional_attributes = additional_attributes or {}
|
||||
condition_options = condition_options or {}
|
||||
|
||||
def state_with_attributes(
|
||||
state: str | None | tuple[str | None, dict],
|
||||
condition_true: bool,
|
||||
state_valid: bool,
|
||||
) -> ConditionStateDescription:
|
||||
"""Return (state, attributes) dict."""
|
||||
if isinstance(state, str) or state is None:
|
||||
return {
|
||||
"included": {
|
||||
"state": state,
|
||||
"attributes": additional_attributes,
|
||||
},
|
||||
"excluded": {
|
||||
"state": state,
|
||||
"attributes": {},
|
||||
},
|
||||
"condition_true": condition_true,
|
||||
"state_valid": state_valid,
|
||||
}
|
||||
return {
|
||||
"included": {
|
||||
"state": state[0],
|
||||
"attributes": state[1] | additional_attributes,
|
||||
},
|
||||
"excluded": {
|
||||
"state": state[0],
|
||||
"attributes": state[1],
|
||||
},
|
||||
"condition_true": condition_true,
|
||||
"state_valid": state_valid,
|
||||
}
|
||||
|
||||
return [
|
||||
(
|
||||
condition,
|
||||
condition_options,
|
||||
list(
|
||||
itertools.chain(
|
||||
(state_with_attributes(None, False, False),),
|
||||
(state_with_attributes(STATE_UNAVAILABLE, False, False),),
|
||||
(state_with_attributes(STATE_UNKNOWN, False, False),),
|
||||
(
|
||||
state_with_attributes(other_state, False, True)
|
||||
for other_state in other_states
|
||||
),
|
||||
(
|
||||
state_with_attributes(target_state, True, True)
|
||||
for target_state in target_states
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_trigger_states(
|
||||
*,
|
||||
trigger: str,
|
||||
@@ -202,7 +286,7 @@ def parametrize_trigger_states(
|
||||
|
||||
def state_with_attributes(
|
||||
state: str | None | tuple[str | None, dict], count: int
|
||||
) -> dict:
|
||||
) -> StateDescription:
|
||||
"""Return (state, attributes) dict."""
|
||||
if isinstance(state, str) or state is None:
|
||||
return {
|
||||
|
||||
@@ -50,7 +50,7 @@ async def test_user_flow_success(
|
||||
# Submit SMS code
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: 123456},
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
@@ -107,7 +107,7 @@ async def test_user_flow_request_sms_code_errors(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: 123456},
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
@@ -142,7 +142,7 @@ async def test_user_flow_verify_phone_number_errors(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: 999999},
|
||||
{CONF_SMS_CODE: "999999"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
@@ -153,7 +153,7 @@ async def test_user_flow_verify_phone_number_errors(
|
||||
mock_auth_client.verify_phone_number.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: 123456},
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
@@ -246,7 +246,7 @@ async def test_reauth_reconfigure_flow(
|
||||
# Submit SMS code
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: 123456},
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -311,7 +311,7 @@ async def test_reauth_reconfigure_flow_invalid_phone_number(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: 123456},
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -358,7 +358,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: 999999},
|
||||
{CONF_SMS_CODE: "999999"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
@@ -369,7 +369,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
|
||||
mock_auth_client.verify_phone_number.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: 123456},
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -436,7 +436,7 @@ async def test_reauth_reconfigure_flow_invalid_user_id(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: 123456},
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Common fixtures for the Hikvision tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hikvision import PLATFORMS
|
||||
from homeassistant.components.hikvision.const import DOMAIN
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -12,6 +13,7 @@ from homeassistant.const import (
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -25,7 +27,20 @@ TEST_DEVICE_NAME = "Front Camera"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
def platforms() -> list[Platform]:
|
||||
"""Platforms, which should be loaded during the test."""
|
||||
return PLATFORMS
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def mock_patch_platforms(platforms: list[Platform]) -> AsyncGenerator[None]:
|
||||
"""Fixture to set up platforms for tests."""
|
||||
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[MagicMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.hikvision.async_setup_entry", return_value=True
|
||||
@@ -58,7 +73,6 @@ def mock_hikcamera() -> Generator[MagicMock]:
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.hikvision.HikCamera",
|
||||
autospec=True,
|
||||
) as hikcamera_mock,
|
||||
patch(
|
||||
"homeassistant.components.hikvision.config_flow.HikCamera",
|
||||
@@ -80,6 +94,15 @@ def mock_hikcamera() -> Generator[MagicMock]:
|
||||
"2024-01-01T00:00:00Z",
|
||||
)
|
||||
camera.get_event_triggers.return_value = {}
|
||||
|
||||
# pyHik 0.4.0 methods
|
||||
camera.get_channels.return_value = [1]
|
||||
camera.get_snapshot.return_value = b"fake_image_data"
|
||||
camera.get_stream_url.return_value = (
|
||||
f"rtsp://{TEST_USERNAME}:{TEST_PASSWORD}"
|
||||
f"@{TEST_HOST}:554/Streaming/Channels/1"
|
||||
)
|
||||
|
||||
yield hikcamera_mock
|
||||
|
||||
|
||||
|
||||
154
tests/components/hikvision/snapshots/test_camera.ambr
Normal file
154
tests/components/hikvision/snapshots/test_camera.ambr
Normal file
@@ -0,0 +1,154 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_entities[camera.front_camera-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'camera',
|
||||
'entity_category': None,
|
||||
'entity_id': 'camera.front_camera',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'hikvision',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'DS-2CD2142FWD-I20170101AAAA_1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[camera.front_camera-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'access_token': '1caab5c3b3',
|
||||
'entity_picture': '/api/camera_proxy/camera.front_camera?token=1caab5c3b3',
|
||||
'friendly_name': 'Front Camera',
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'camera.front_camera',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'idle',
|
||||
})
|
||||
# ---
|
||||
# name: test_nvr_entities[camera.front_camera_channel_1-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'camera',
|
||||
'entity_category': None,
|
||||
'entity_id': 'camera.front_camera_channel_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'hikvision',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'DS-2CD2142FWD-I20170101AAAA_1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_nvr_entities[camera.front_camera_channel_1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'access_token': '1caab5c3b3',
|
||||
'entity_picture': '/api/camera_proxy/camera.front_camera_channel_1?token=1caab5c3b3',
|
||||
'friendly_name': 'Front Camera Channel 1',
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'camera.front_camera_channel_1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'idle',
|
||||
})
|
||||
# ---
|
||||
# name: test_nvr_entities[camera.front_camera_channel_2-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'camera',
|
||||
'entity_category': None,
|
||||
'entity_id': 'camera.front_camera_channel_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'hikvision',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'DS-2CD2142FWD-I20170101AAAA_2',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_nvr_entities[camera.front_camera_channel_2-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'access_token': '1caab5c3b3',
|
||||
'entity_picture': '/api/camera_proxy/camera.front_camera_channel_2?token=1caab5c3b3',
|
||||
'friendly_name': 'Front Camera Channel 2',
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'camera.front_camera_channel_2',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'idle',
|
||||
})
|
||||
# ---
|
||||
@@ -17,6 +17,7 @@ from homeassistant.const import (
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
STATE_OFF,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
@@ -39,6 +40,12 @@ from .conftest import (
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Platforms, which should be loaded during the test."""
|
||||
return [Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_all_entities(
|
||||
hass: HomeAssistant,
|
||||
@@ -132,11 +139,11 @@ async def test_binary_sensor_nvr_device(
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
# NVR sensors should include channel number in name
|
||||
state = hass.states.get("binary_sensor.front_camera_motion_1")
|
||||
# NVR sensors are on per-channel devices
|
||||
state = hass.states.get("binary_sensor.front_camera_channel_1_motion")
|
||||
assert state is not None
|
||||
|
||||
state = hass.states.get("binary_sensor.front_camera_motion_2")
|
||||
state = hass.states.get("binary_sensor.front_camera_channel_2_motion")
|
||||
assert state is not None
|
||||
|
||||
|
||||
|
||||
165
tests/components/hikvision/test_camera.py
Normal file
165
tests/components/hikvision/test_camera.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Test Hikvision cameras."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.camera import async_get_image, async_get_stream_source
|
||||
from homeassistant.components.hikvision.const import DOMAIN
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import TEST_DEVICE_ID, TEST_DEVICE_NAME, TEST_HOST, TEST_PASSWORD
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Return platforms to load during test."""
|
||||
return [Platform.CAMERA]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_all_entities(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hikcamera: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test all camera entities."""
|
||||
with patch("random.SystemRandom.getrandbits", return_value=123123123123):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_nvr_entities(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hikcamera: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test NVR camera entities with multiple channels."""
|
||||
mock_hikcamera.return_value.get_type = "NVR"
|
||||
mock_hikcamera.return_value.get_channels.return_value = [1, 2]
|
||||
|
||||
with patch("random.SystemRandom.getrandbits", return_value=123123123123):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_camera_device_info(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hikcamera: MagicMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test camera is linked to device."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, TEST_DEVICE_ID)}
|
||||
)
|
||||
assert device_entry is not None
|
||||
assert device_entry.name == TEST_DEVICE_NAME
|
||||
assert device_entry.manufacturer == "Hikvision"
|
||||
assert device_entry.model == "Camera"
|
||||
|
||||
|
||||
async def test_camera_no_channels_creates_single_camera(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hikcamera: MagicMock,
|
||||
) -> None:
|
||||
"""Test camera created when device returns no channels."""
|
||||
mock_hikcamera.return_value.get_channels.return_value = []
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
# Single camera should be created for channel 1
|
||||
states = hass.states.async_entity_ids("camera")
|
||||
assert len(states) == 1
|
||||
|
||||
state = hass.states.get("camera.front_camera")
|
||||
assert state is not None
|
||||
|
||||
|
||||
async def test_camera_image(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hikcamera: MagicMock,
|
||||
) -> None:
|
||||
"""Test getting camera image."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
image = await async_get_image(hass, "camera.front_camera")
|
||||
assert image.content == b"fake_image_data"
|
||||
|
||||
# Verify get_snapshot was called with channel 1
|
||||
mock_hikcamera.return_value.get_snapshot.assert_called_with(1)
|
||||
|
||||
|
||||
async def test_camera_image_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hikcamera: MagicMock,
|
||||
) -> None:
|
||||
"""Test camera image error handling."""
|
||||
mock_hikcamera.return_value.get_snapshot.side_effect = Exception("Connection error")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Error getting image"):
|
||||
await async_get_image(hass, "camera.front_camera")
|
||||
|
||||
|
||||
async def test_camera_stream_source(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hikcamera: MagicMock,
|
||||
) -> None:
|
||||
"""Test camera stream source URL."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
stream_url = await async_get_stream_source(hass, "camera.front_camera")
|
||||
|
||||
# Verify RTSP URL from library
|
||||
assert stream_url is not None
|
||||
assert stream_url.startswith("rtsp://")
|
||||
assert f"@{TEST_HOST}:554/Streaming/Channels/1" in stream_url
|
||||
|
||||
# Verify get_stream_url was called with channel 1
|
||||
mock_hikcamera.return_value.get_stream_url.assert_called_with(1)
|
||||
|
||||
|
||||
async def test_camera_stream_source_nvr(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_hikcamera: MagicMock,
|
||||
) -> None:
|
||||
"""Test NVR camera stream source URL."""
|
||||
mock_hikcamera.return_value.get_type = "NVR"
|
||||
mock_hikcamera.return_value.get_channels.return_value = [2]
|
||||
mock_hikcamera.return_value.get_stream_url.return_value = (
|
||||
f"rtsp://admin:{TEST_PASSWORD}@{TEST_HOST}:554/Streaming/Channels/201"
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
stream_url = await async_get_stream_source(hass, "camera.front_camera_channel_2")
|
||||
|
||||
# NVR channel 2 should use stream channel 201
|
||||
assert stream_url is not None
|
||||
assert f"@{TEST_HOST}:554/Streaming/Channels/201" in stream_url
|
||||
|
||||
# Verify get_stream_url was called with channel 2
|
||||
mock_hikcamera.return_value.get_stream_url.assert_called_with(2)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test light conditions."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -13,24 +14,18 @@ from homeassistant.const import (
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.components import (
|
||||
ConditionStateDescription,
|
||||
parametrize_condition_states,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
INVALID_STATES = [
|
||||
{"state": STATE_UNAVAILABLE, "attributes": {}},
|
||||
{"state": STATE_UNKNOWN, "attributes": {}},
|
||||
{"state": None, "attributes": {}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
@@ -76,15 +71,15 @@ async def setup_automation_with_light_condition(
|
||||
)
|
||||
|
||||
|
||||
async def has_call_after_trigger(
|
||||
async def has_single_call_after_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> bool:
|
||||
"""Check if there are service calls after the trigger event."""
|
||||
"""Check if there is a single service call after the trigger event."""
|
||||
hass.bus.async_fire("test_event")
|
||||
await hass.async_block_till_done()
|
||||
has_calls = len(service_calls) == 1
|
||||
num_calls = len(service_calls)
|
||||
service_calls.clear()
|
||||
return has_calls
|
||||
return num_calls == 1
|
||||
|
||||
|
||||
@pytest.fixture(name="enable_experimental_triggers_conditions")
|
||||
@@ -125,17 +120,17 @@ async def test_light_conditions_gated_by_labs_flag(
|
||||
parametrize_target_entities("light"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "target_state", "other_state"),
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
(
|
||||
"light.is_on",
|
||||
{"state": STATE_ON, "attributes": {}},
|
||||
{"state": STATE_OFF, "attributes": {}},
|
||||
*parametrize_condition_states(
|
||||
condition="light.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
(
|
||||
"light.is_off",
|
||||
{"state": STATE_OFF, "attributes": {}},
|
||||
{"state": STATE_ON, "attributes": {}},
|
||||
*parametrize_condition_states(
|
||||
condition="light.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -148,15 +143,15 @@ async def test_light_state_condition_behavior_any(
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
target_state: str,
|
||||
other_state: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the light state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_lights) - {entity_id}
|
||||
|
||||
# Set all lights, including the tested light, to the initial state
|
||||
for eid in target_lights:
|
||||
set_or_remove_state(hass, eid, other_state)
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await setup_automation_with_light_condition(
|
||||
@@ -167,38 +162,29 @@ async def test_light_state_condition_behavior_any(
|
||||
)
|
||||
|
||||
# Set state for switches to ensure that they don't impact the condition
|
||||
for eid in target_switches:
|
||||
set_or_remove_state(hass, eid, other_state)
|
||||
await hass.async_block_till_done()
|
||||
assert not await has_call_after_trigger(hass, service_calls)
|
||||
for state in states:
|
||||
for eid in target_switches:
|
||||
set_or_remove_state(hass, eid, state["included"])
|
||||
await hass.async_block_till_done()
|
||||
assert not await has_single_call_after_trigger(hass, service_calls)
|
||||
|
||||
for eid in target_switches:
|
||||
set_or_remove_state(hass, eid, target_state)
|
||||
await hass.async_block_till_done()
|
||||
assert not await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
# Set one light to the condition state -> condition pass
|
||||
set_or_remove_state(hass, entity_id, target_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
# Set all remaining lights to the condition state -> condition pass
|
||||
for eid in other_entity_ids:
|
||||
set_or_remove_state(hass, eid, target_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
for invalid_state in INVALID_STATES:
|
||||
# Set one light to the invalid state -> condition pass if there are
|
||||
# other lights in the condition state
|
||||
set_or_remove_state(hass, entity_id, invalid_state)
|
||||
assert await has_call_after_trigger(hass, service_calls) == bool(
|
||||
entities_in_target - 1
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
await has_single_call_after_trigger(hass, service_calls)
|
||||
== state["condition_true"]
|
||||
)
|
||||
|
||||
for invalid_state in INVALID_STATES:
|
||||
# Set all lights to invalid state -> condition fail
|
||||
for eid in other_entity_ids:
|
||||
set_or_remove_state(hass, eid, invalid_state)
|
||||
assert not await has_call_after_trigger(hass, service_calls)
|
||||
# Check if changing other lights also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
await has_single_call_after_trigger(hass, service_calls)
|
||||
== state["condition_true"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@@ -207,17 +193,17 @@ async def test_light_state_condition_behavior_any(
|
||||
parametrize_target_entities("light"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "target_state", "other_state"),
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
(
|
||||
"light.is_on",
|
||||
{"state": STATE_ON, "attributes": {}},
|
||||
{"state": STATE_OFF, "attributes": {}},
|
||||
*parametrize_condition_states(
|
||||
condition="light.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
),
|
||||
(
|
||||
"light.is_off",
|
||||
{"state": STATE_OFF, "attributes": {}},
|
||||
{"state": STATE_ON, "attributes": {}},
|
||||
*parametrize_condition_states(
|
||||
condition="light.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -229,8 +215,8 @@ async def test_light_state_condition_behavior_all(
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
target_state: str,
|
||||
other_state: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the light state condition with the 'all' behavior."""
|
||||
# Set state for two switches to ensure that they don't impact the condition
|
||||
@@ -241,7 +227,7 @@ async def test_light_state_condition_behavior_all(
|
||||
|
||||
# Set all lights, including the tested light, to the initial state
|
||||
for eid in target_lights:
|
||||
set_or_remove_state(hass, eid, other_state)
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await setup_automation_with_light_condition(
|
||||
@@ -251,27 +237,22 @@ async def test_light_state_condition_behavior_all(
|
||||
behavior="all",
|
||||
)
|
||||
|
||||
# No lights on the condition state
|
||||
assert not await has_call_after_trigger(hass, service_calls)
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
# Set one light to the condition state -> condition fail
|
||||
set_or_remove_state(hass, entity_id, target_state)
|
||||
assert await has_call_after_trigger(hass, service_calls) == (
|
||||
entities_in_target == 1
|
||||
)
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert await has_single_call_after_trigger(hass, service_calls) == (
|
||||
(not state["state_valid"])
|
||||
or (state["condition_true"] and entities_in_target == 1)
|
||||
)
|
||||
|
||||
# Set all remaining lights to the condition state -> condition pass
|
||||
for eid in other_entity_ids:
|
||||
set_or_remove_state(hass, eid, target_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for invalid_state in INVALID_STATES:
|
||||
# Set one light to the invalid state -> condition still pass
|
||||
set_or_remove_state(hass, entity_id, invalid_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
for invalid_state in INVALID_STATES:
|
||||
# Set all lights to unavailable -> condition passes
|
||||
for eid in other_entity_ids:
|
||||
set_or_remove_state(hass, eid, invalid_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert await has_single_call_after_trigger(hass, service_calls) == (
|
||||
(not state["state_valid"]) or state["condition_true"]
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test the MELCloud ATW zone sensor."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -8,32 +8,45 @@ from homeassistant.components.melcloud.sensor import ATW_ZONE_SENSORS, AtwZoneSe
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
"""Mock MELCloud device."""
|
||||
with patch("homeassistant.components.melcloud.MelCloudDevice") as mock:
|
||||
mock.name = "name"
|
||||
mock.device.serial = 1234
|
||||
mock.device.mac = "11:11:11:11:11:11"
|
||||
def mock_coordinator():
|
||||
"""Mock MELCloud coordinator."""
|
||||
with patch(
|
||||
"homeassistant.components.melcloud.coordinator.MelCloudDeviceUpdateCoordinator"
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device(mock_coordinator):
|
||||
"""Mock MELCloud device."""
|
||||
mock = MagicMock()
|
||||
mock.name = "name"
|
||||
mock.device.serial = 1234
|
||||
mock.device.mac = "11:11:11:11:11:11"
|
||||
mock.zone_device_info.return_value = {}
|
||||
mock.coordinator = mock_coordinator
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_zone_1():
|
||||
"""Mock zone 1."""
|
||||
with patch("pymelcloud.atw_device.Zone") as mock:
|
||||
mock.zone_index = 1
|
||||
yield mock
|
||||
mock = MagicMock()
|
||||
mock.zone_index = 1
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_zone_2():
|
||||
"""Mock zone 2."""
|
||||
with patch("pymelcloud.atw_device.Zone") as mock:
|
||||
mock.zone_index = 2
|
||||
yield mock
|
||||
mock = MagicMock()
|
||||
mock.zone_index = 2
|
||||
return mock
|
||||
|
||||
|
||||
def test_zone_unique_ids(mock_device, mock_zone_1, mock_zone_2) -> None:
|
||||
def test_zone_unique_ids(
|
||||
mock_coordinator, mock_device, mock_zone_1, mock_zone_2
|
||||
) -> None:
|
||||
"""Test unique id generation correctness."""
|
||||
sensor_1 = AtwZoneSensor(
|
||||
mock_device,
|
||||
|
||||
@@ -75,7 +75,11 @@ async def test_form(hass: HomeAssistant, mock_login, mock_get_devices) -> None:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "reason"),
|
||||
[(ClientError(), "cannot_connect"), (TimeoutError(), "cannot_connect")],
|
||||
[
|
||||
(ClientError(), "cannot_connect"),
|
||||
(TimeoutError(), "cannot_connect"),
|
||||
(AttributeError(), "invalid_auth"),
|
||||
],
|
||||
)
|
||||
async def test_form_errors(
|
||||
hass: HomeAssistant, mock_login, mock_get_devices, error, reason
|
||||
|
||||
572
tests/components/mill/test_climate.py
Normal file
572
tests/components/mill/test_climate.py
Normal file
@@ -0,0 +1,572 @@
|
||||
"""Tests for Mill climate."""
|
||||
|
||||
import contextlib
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from mill import Heater
|
||||
from mill_local import OperationMode
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import mill
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.components.mill.const import DOMAIN
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
HEATER_ID = "dev_id"
|
||||
HEATER_NAME = "heater_name"
|
||||
ENTITY_CLIMATE = f"climate.{HEATER_NAME}"
|
||||
|
||||
TEST_SET_TEMPERATURE = 25
|
||||
TEST_AMBIENT_TEMPERATURE = 20
|
||||
|
||||
NULL_EFFECT = nullcontext()
|
||||
|
||||
## MILL AND LOCAL MILL FIXTURES
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_mill():
|
||||
"""Mock the mill.Mill object.
|
||||
|
||||
It is imported and initialized only in /homeassistant/components/mill/__init__.py
|
||||
"""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.mill.Mill",
|
||||
autospec=True,
|
||||
) as mock_mill_class,
|
||||
):
|
||||
mill = mock_mill_class.return_value
|
||||
mill.connect.return_value = True
|
||||
mill.fetch_heater_and_sensor_data.return_value = {}
|
||||
mill.fetch_historic_energy_usage.return_value = {}
|
||||
yield mill
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_mill_local():
|
||||
"""Mock the mill_local.Mill object."""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.mill.MillLocal",
|
||||
autospec=True,
|
||||
) as mock_mill_local_class,
|
||||
):
|
||||
milllocal = mock_mill_local_class.return_value
|
||||
milllocal.url = "http://dummy.url"
|
||||
milllocal.name = HEATER_NAME
|
||||
milllocal.mac_address = "dead:beef"
|
||||
milllocal.version = "0x210927"
|
||||
milllocal.connect.return_value = {
|
||||
"name": milllocal.name,
|
||||
"mac_address": milllocal.mac_address,
|
||||
"version": milllocal.version,
|
||||
"operation_key": "",
|
||||
"status": "ok",
|
||||
}
|
||||
status = {
|
||||
"ambient_temperature": TEST_AMBIENT_TEMPERATURE,
|
||||
"set_temperature": TEST_AMBIENT_TEMPERATURE,
|
||||
"current_power": 0,
|
||||
"control_signal": 0,
|
||||
"raw_ambient_temperature": TEST_AMBIENT_TEMPERATURE,
|
||||
"operation_mode": OperationMode.OFF.value,
|
||||
}
|
||||
milllocal.fetch_heater_and_sensor_data.return_value = status
|
||||
milllocal._status = status
|
||||
yield milllocal
|
||||
|
||||
|
||||
## CLOUD HEATER INTEGRATION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cloud_heater(hass: HomeAssistant, mock_mill: MagicMock) -> Heater:
|
||||
"""Load Mill integration and creates one cloud heater."""
|
||||
|
||||
heater = Heater(
|
||||
name=HEATER_NAME,
|
||||
device_id=HEATER_ID,
|
||||
available=True,
|
||||
is_heating=False,
|
||||
power_status=False,
|
||||
current_temp=float(TEST_AMBIENT_TEMPERATURE),
|
||||
set_temp=float(TEST_AMBIENT_TEMPERATURE),
|
||||
)
|
||||
|
||||
devices = {HEATER_ID: heater}
|
||||
|
||||
mock_mill.fetch_heater_and_sensor_data.return_value = devices
|
||||
mock_mill.devices = devices
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
mill.CONF_USERNAME: "user",
|
||||
mill.CONF_PASSWORD: "pswd",
|
||||
mill.CONNECTION_TYPE: mill.CLOUD,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
# We just need to load the climate component.
|
||||
with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return heater
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cloud_heater_set_temp(mock_mill: MagicMock, cloud_heater: MagicMock):
|
||||
"""Gets mock for the cloud heater `set_heater_temp` method."""
|
||||
return mock_mill.set_heater_temp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cloud_heater_control(mock_mill: MagicMock, cloud_heater: MagicMock):
|
||||
"""Gets mock for the cloud heater `heater_control` method."""
|
||||
return mock_mill.heater_control
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def functional_cloud_heater(
|
||||
cloud_heater: MagicMock,
|
||||
cloud_heater_set_temp: MagicMock,
|
||||
cloud_heater_control: MagicMock,
|
||||
) -> Heater:
|
||||
"""Make sure the cloud heater is "functional".
|
||||
|
||||
This will create a pseudo-functional cloud heater,
|
||||
meaning that function calls will edit the original cloud heater
|
||||
in a similar way that the API would.
|
||||
"""
|
||||
|
||||
def calculate_heating():
|
||||
if (
|
||||
cloud_heater.power_status
|
||||
and cloud_heater.set_temp > cloud_heater.current_temp
|
||||
):
|
||||
cloud_heater.is_heating = True
|
||||
|
||||
def set_temperature(device_id: str, set_temp: float):
|
||||
assert device_id == HEATER_ID, "set_temperature called with wrong device_id"
|
||||
|
||||
cloud_heater.set_temp = set_temp
|
||||
|
||||
calculate_heating()
|
||||
|
||||
def heater_control(device_id: str, power_status: bool):
|
||||
assert device_id == HEATER_ID, "set_temperature called with wrong device_id"
|
||||
|
||||
# power_status gives the "do we want to heat, Y/N", while is_heating is based on temperature and internal state and whatnot.
|
||||
cloud_heater.power_status = power_status
|
||||
|
||||
calculate_heating()
|
||||
|
||||
cloud_heater_set_temp.side_effect = set_temperature
|
||||
cloud_heater_control.side_effect = heater_control
|
||||
|
||||
return cloud_heater
|
||||
|
||||
|
||||
## LOCAL HEATER INTEGRATION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_heater(hass: HomeAssistant, mock_mill_local: MagicMock) -> dict:
|
||||
"""Local Mill Heater.
|
||||
|
||||
This returns a by-reference status dict
|
||||
with which this heater's information is organised and updated.
|
||||
"""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
mill.CONF_IP_ADDRESS: "192.168.1.59",
|
||||
mill.CONNECTION_TYPE: mill.LOCAL,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
# We just need to load the climate component.
|
||||
with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_mill_local._status
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_heater_set_target_temperature(
|
||||
mock_mill_local: MagicMock, local_heater: MagicMock
|
||||
):
|
||||
"""Gets mock for the local heater `set_target_temperature` method."""
|
||||
return mock_mill_local.set_target_temperature
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_heater_set_mode_control_individually(
|
||||
mock_mill_local: MagicMock, local_heater: MagicMock
|
||||
):
|
||||
"""Gets mock for the local heater `set_operation_mode_control_individually` method."""
|
||||
return mock_mill_local.set_operation_mode_control_individually
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_heater_set_mode_off(
|
||||
mock_mill_local: MagicMock, local_heater: MagicMock
|
||||
):
|
||||
"""Gets mock for the local heater `set_operation_mode_off` method."""
|
||||
return mock_mill_local.set_operation_mode_off
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def functional_local_heater(
|
||||
mock_mill_local: MagicMock,
|
||||
local_heater_set_target_temperature: MagicMock,
|
||||
local_heater_set_mode_control_individually: MagicMock,
|
||||
local_heater_set_mode_off: MagicMock,
|
||||
local_heater: MagicMock,
|
||||
) -> None:
|
||||
"""Make sure the local heater is "functional".
|
||||
|
||||
This will create a pseudo-functional local heater,
|
||||
meaning that function calls will edit the original local heater
|
||||
in a similar way that the API would.
|
||||
"""
|
||||
|
||||
def set_temperature(target_temperature: float):
|
||||
local_heater["set_temperature"] = target_temperature
|
||||
|
||||
def set_operation_mode(operation_mode: OperationMode):
|
||||
local_heater["operation_mode"] = operation_mode.value
|
||||
|
||||
def mode_control_individually():
|
||||
set_operation_mode(OperationMode.CONTROL_INDIVIDUALLY)
|
||||
|
||||
def mode_off():
|
||||
set_operation_mode(OperationMode.OFF)
|
||||
|
||||
local_heater_set_target_temperature.side_effect = set_temperature
|
||||
local_heater_set_mode_control_individually.side_effect = mode_control_individually
|
||||
local_heater_set_mode_off.side_effect = mode_off
|
||||
|
||||
|
||||
### CLOUD
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"before_state",
|
||||
"before_attrs",
|
||||
"service_name",
|
||||
"service_params",
|
||||
"effect",
|
||||
"heater_control_calls",
|
||||
"heater_set_temp_calls",
|
||||
"after_state",
|
||||
"after_attrs",
|
||||
),
|
||||
[
|
||||
# set_hvac_mode
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
NULL_EFFECT,
|
||||
[call(HEATER_ID, power_status=True)],
|
||||
[],
|
||||
HVACMode.HEAT,
|
||||
{},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
NULL_EFFECT,
|
||||
[call(HEATER_ID, power_status=False)],
|
||||
[],
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
pytest.raises(HomeAssistantError),
|
||||
[],
|
||||
[],
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
),
|
||||
# set_temperature (with hvac mode)
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
NULL_EFFECT,
|
||||
[call(HEATER_ID, power_status=True)],
|
||||
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.HEAT,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
NULL_EFFECT,
|
||||
[call(HEATER_ID, power_status=False)],
|
||||
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
NULL_EFFECT,
|
||||
[],
|
||||
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
pytest.raises(HomeAssistantError),
|
||||
# MillHeater will set the temperature before calling async_handle_set_hvac_mode,
|
||||
# meaning an invalid HVAC mode will raise only after the temperature is set.
|
||||
[],
|
||||
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
# likewise, in this test, it hasn't had the chance to update its ambient temperature,
|
||||
# because the exception is raised before a refresh can be requested from the coordinator
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_cloud_heater(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
functional_cloud_heater: MagicMock,
|
||||
cloud_heater_control: MagicMock,
|
||||
cloud_heater_set_temp: MagicMock,
|
||||
before_state: HVACMode,
|
||||
before_attrs: dict,
|
||||
service_name: str,
|
||||
service_params: dict,
|
||||
effect: "contextlib.AbstractContextManager",
|
||||
heater_control_calls: list,
|
||||
heater_set_temp_calls: list,
|
||||
after_state: HVACMode,
|
||||
after_attrs: dict,
|
||||
) -> None:
|
||||
"""Tests setting HVAC mode (directly or through set_temperature) for a cloud heater."""
|
||||
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state is not None
|
||||
assert state.state == before_state
|
||||
for attr, value in before_attrs.items():
|
||||
assert state.attributes.get(attr) == value
|
||||
|
||||
with effect:
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
service_name,
|
||||
service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
cloud_heater_control.assert_has_calls(heater_control_calls)
|
||||
cloud_heater_set_temp.assert_has_calls(heater_set_temp_calls)
|
||||
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state is not None
|
||||
assert state.state == after_state
|
||||
for attr, value in after_attrs.items():
|
||||
assert state.attributes.get(attr) == value
|
||||
|
||||
|
||||
### LOCAL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"before_state",
|
||||
"before_attrs",
|
||||
"service_name",
|
||||
"service_params",
|
||||
"effect",
|
||||
"heater_mode_set_individually_calls",
|
||||
"heater_mode_set_off_calls",
|
||||
"heater_set_target_temperature_calls",
|
||||
"after_state",
|
||||
"after_attrs",
|
||||
),
|
||||
[
|
||||
# set_hvac_mode
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
NULL_EFFECT,
|
||||
[call()],
|
||||
[],
|
||||
[],
|
||||
HVACMode.HEAT,
|
||||
{},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
NULL_EFFECT,
|
||||
[],
|
||||
[call()],
|
||||
[],
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
pytest.raises(HomeAssistantError),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
),
|
||||
# set_temperature (with hvac mode)
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
NULL_EFFECT,
|
||||
[call()],
|
||||
[],
|
||||
[call(float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.HEAT,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
NULL_EFFECT,
|
||||
[],
|
||||
[call()],
|
||||
[call(float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
NULL_EFFECT,
|
||||
[],
|
||||
[],
|
||||
[call(float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
pytest.raises(HomeAssistantError),
|
||||
# LocalMillHeater will set the temperature before calling async_handle_set_hvac_mode,
|
||||
# meaning an invalid HVAC mode will raise only after the temperature is set.
|
||||
[],
|
||||
[],
|
||||
[call(float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
# likewise, in this test, it hasn't had the chance to update its ambient temperature,
|
||||
# because the exception is raised before a refresh can be requested from the coordinator
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_local_heater(
|
||||
hass: HomeAssistant,
|
||||
functional_local_heater: MagicMock,
|
||||
local_heater_set_mode_control_individually: MagicMock,
|
||||
local_heater_set_mode_off: MagicMock,
|
||||
local_heater_set_target_temperature: MagicMock,
|
||||
before_state: HVACMode,
|
||||
before_attrs: dict,
|
||||
service_name: str,
|
||||
service_params: dict,
|
||||
effect: "contextlib.AbstractContextManager",
|
||||
heater_mode_set_individually_calls: list,
|
||||
heater_mode_set_off_calls: list,
|
||||
heater_set_target_temperature_calls: list,
|
||||
after_state: HVACMode,
|
||||
after_attrs: dict,
|
||||
) -> None:
|
||||
"""Tests setting HVAC mode (directly or through set_temperature) for a local heater."""
|
||||
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state is not None
|
||||
assert state.state == before_state
|
||||
for attr, value in before_attrs.items():
|
||||
assert state.attributes.get(attr) == value
|
||||
|
||||
with effect:
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
service_name,
|
||||
service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
local_heater_set_mode_control_individually.assert_has_calls(
|
||||
heater_mode_set_individually_calls
|
||||
)
|
||||
local_heater_set_mode_off.assert_has_calls(heater_mode_set_off_calls)
|
||||
local_heater_set_target_temperature.assert_has_calls(
|
||||
heater_set_target_temperature_calls
|
||||
)
|
||||
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state is not None
|
||||
assert state.state == after_state
|
||||
for attr, value in after_attrs.items():
|
||||
assert state.attributes.get(attr) == value
|
||||
52
tests/components/namecheapdns/conftest.py
Normal file
52
tests/components/namecheapdns/conftest.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Common fixtures for the Namecheap DynamicDNS tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.namecheapdns.const import DOMAIN
|
||||
from homeassistant.const import CONF_DOMAIN, CONF_HOST, CONF_PASSWORD
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_HOST = "home"
|
||||
TEST_DOMAIN = "example.com"
|
||||
TEST_PASSWORD = "test-password"
|
||||
|
||||
TEST_USER_INPUT = {
|
||||
CONF_HOST: TEST_HOST,
|
||||
CONF_DOMAIN: TEST_DOMAIN,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.namecheapdns.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_namecheap")
|
||||
def mock_update_namecheapdns() -> Generator[AsyncMock]:
|
||||
"""Mock update_namecheapdns."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.namecheapdns.config_flow.update_namecheapdns",
|
||||
return_value=True,
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Mock Namecheap Dynamic DNS configuration entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=f"{TEST_HOST}.{TEST_DOMAIN}",
|
||||
data=TEST_USER_INPUT,
|
||||
entry_id="12345",
|
||||
)
|
||||
142
tests/components/namecheapdns/test_config_flow.py
Normal file
142
tests/components/namecheapdns/test_config_flow.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Test the Namecheap DynamicDNS config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiohttp import ClientError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.namecheapdns.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import TEST_USER_INPUT
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_namecheap")
|
||||
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], TEST_USER_INPUT
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "home.example.com"
|
||||
assert result["data"] == TEST_USER_INPUT
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "text_error"),
|
||||
[
|
||||
(ValueError, "unknown"),
|
||||
(False, "update_failed"),
|
||||
(ClientError, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_form_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_namecheap: AsyncMock,
|
||||
side_effect: Exception | bool,
|
||||
text_error: str,
|
||||
) -> None:
|
||||
"""Test we handle errors."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_namecheap.side_effect = [side_effect]
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], TEST_USER_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": text_error}
|
||||
|
||||
mock_namecheap.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], TEST_USER_INPUT
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "home.example.com"
|
||||
assert result["data"] == TEST_USER_INPUT
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_namecheap")
|
||||
async def test_import(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test import flow."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=TEST_USER_INPUT,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "home.example.com"
|
||||
assert result["data"] == TEST_USER_INPUT
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert issue_registry.async_get_issue(
|
||||
domain=HOMEASSISTANT_DOMAIN,
|
||||
issue_id=f"deprecated_yaml_{DOMAIN}",
|
||||
)
|
||||
|
||||
|
||||
async def test_import_exception(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
mock_namecheap: AsyncMock,
|
||||
) -> None:
|
||||
"""Test import flow failed."""
|
||||
mock_namecheap.side_effect = [False]
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=TEST_USER_INPUT,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "update_failed"
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 0
|
||||
|
||||
assert issue_registry.async_get_issue(
|
||||
domain=DOMAIN,
|
||||
issue_id="deprecated_yaml_import_issue_error",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_namecheap")
|
||||
async def test_init_import_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test yaml triggers import flow."""
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
DOMAIN,
|
||||
{DOMAIN: TEST_USER_INPUT},
|
||||
)
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user