mirror of
https://github.com/home-assistant/core.git
synced 2025-12-05 15:38:11 +00:00
Compare commits
151 Commits
knx-data-s
...
2025.12.0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf60f9d15 | ||
|
|
7dddd89ac2 | ||
|
|
a2322ef3c7 | ||
|
|
5f6ef2109a | ||
|
|
44f0a8899a | ||
|
|
78fa29b41f | ||
|
|
06d4f085c0 | ||
|
|
a9deb2a08a | ||
|
|
0d26d22986 | ||
|
|
062366966b | ||
|
|
1b8a50e80a | ||
|
|
59761385f0 | ||
|
|
6536d348e5 | ||
|
|
c157c83d54 | ||
|
|
77425cc40f | ||
|
|
c4b67329c3 | ||
|
|
c1f8c89bd0 | ||
|
|
b1bf6f5678 | ||
|
|
d347136188 | ||
|
|
a4319f3bf8 | ||
|
|
db27aee62a | ||
|
|
a7446b3da9 | ||
|
|
7fc5464621 | ||
|
|
a00b50c195 | ||
|
|
738fb59efa | ||
|
|
04e512a48e | ||
|
|
c63aca2d9b | ||
|
|
c95203e095 | ||
|
|
259235ceeb | ||
|
|
c7f1729300 | ||
|
|
065329e668 | ||
|
|
a93ed69fe4 | ||
|
|
189497622d | ||
|
|
a466fc4a01 | ||
|
|
8a968b5d0e | ||
|
|
3baee5c4ac | ||
|
|
f624a43770 | ||
|
|
242935774b | ||
|
|
051ad5878f | ||
|
|
b2156c1d4c | ||
|
|
7d4394f7ed | ||
|
|
4df172374c | ||
|
|
c97755472e | ||
|
|
ebc9060b01 | ||
|
|
bbcc2a94b3 | ||
|
|
692188fa85 | ||
|
|
2c993ea5a2 | ||
|
|
c765776726 | ||
|
|
723365d8e6 | ||
|
|
3d8e136049 | ||
|
|
2fe9fc7ee3 | ||
|
|
e11e31a1a0 | ||
|
|
989407047d | ||
|
|
6d3087c5a4 | ||
|
|
9bd3c35231 | ||
|
|
b7e97971cf | ||
|
|
4d232c63f8 | ||
|
|
6fc000ee2a | ||
|
|
623d3ecde5 | ||
|
|
0fbb3215b4 | ||
|
|
c82ce1ff89 | ||
|
|
8c891a20e5 | ||
|
|
97c50b2d86 | ||
|
|
ef4062a565 | ||
|
|
e31cce5d9b | ||
|
|
21f6b9a53a | ||
|
|
047e549112 | ||
|
|
4c4aecd9a7 | ||
|
|
733496ff3f | ||
|
|
f682e93243 | ||
|
|
c8fa5b0290 | ||
|
|
8ff2a22664 | ||
|
|
c174ab2d96 | ||
|
|
10f0ff7bd7 | ||
|
|
4a4eb33bf7 | ||
|
|
8199c4e5de | ||
|
|
0bfa8318a7 | ||
|
|
ed66a4920c | ||
|
|
f51007c448 | ||
|
|
bd44402b04 | ||
|
|
99fa92d966 | ||
|
|
1cb8f19020 | ||
|
|
81cdbdd4df | ||
|
|
c82706eaf5 | ||
|
|
07f9bec8b6 | ||
|
|
33d576234b | ||
|
|
9e2b4615f1 | ||
|
|
a46dc7e05f | ||
|
|
7dd9953345 | ||
|
|
1145026190 | ||
|
|
d8f9574bc3 | ||
|
|
e91f8d3a81 | ||
|
|
8c0fd0565e | ||
|
|
cc620fc0f8 | ||
|
|
5a89332680 | ||
|
|
1831c5e249 | ||
|
|
dddd2503ea | ||
|
|
91ba510a1e | ||
|
|
6e5e739496 | ||
|
|
6b39eb069c | ||
|
|
847c332c70 | ||
|
|
1a19f3b527 | ||
|
|
8110935d2d | ||
|
|
af69da94f5 | ||
|
|
c1cf17d4db | ||
|
|
6079637909 | ||
|
|
9268e12b20 | ||
|
|
d07993f4a4 | ||
|
|
441cb4197c | ||
|
|
d2a095588d | ||
|
|
f2578da7db | ||
|
|
22200d6804 | ||
|
|
8a4e5c3a28 | ||
|
|
30f31c7d8c | ||
|
|
232c4255a1 | ||
|
|
236f7cd22c | ||
|
|
5948ff2e31 | ||
|
|
380127bc70 | ||
|
|
b6a1e8251a | ||
|
|
c20236717c | ||
|
|
1fd9feaace | ||
|
|
7ce072b4dc | ||
|
|
45aa0399c7 | ||
|
|
d82b3871c1 | ||
|
|
8f6d1162e5 | ||
|
|
dafce97341 | ||
|
|
ffd5d33bbc | ||
|
|
bac32bc379 | ||
|
|
6344837009 | ||
|
|
9079ff5ea8 | ||
|
|
cd646aea11 | ||
|
|
b3a93d9fab | ||
|
|
db98fb138b | ||
|
|
348c8bca7c | ||
|
|
e30707ad5e | ||
|
|
3fa4dcb980 | ||
|
|
57835efc9d | ||
|
|
f8d5a8bc58 | ||
|
|
3f1f8da6f5 | ||
|
|
55613f56b6 | ||
|
|
3ee2a78663 | ||
|
|
814a0c4cc9 | ||
|
|
71b674d8f1 | ||
|
|
c952fc5e31 | ||
|
|
8c3d40a348 | ||
|
|
2451dfb63d | ||
|
|
8e5921eab6 | ||
|
|
bc730da9b1 | ||
|
|
28b7ebea6e | ||
|
|
cfa447c7a9 | ||
|
|
f64c870e42 |
42
.github/workflows/builder.yml
vendored
42
.github/workflows/builder.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
@@ -190,8 +190,7 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- &install_cosign
|
||||
name: Install Cosign
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
@@ -273,7 +272,7 @@ jobs:
|
||||
- green
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set build additional args
|
||||
run: |
|
||||
@@ -295,7 +294,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@2025.11.0
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -311,7 +310,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master
|
||||
@@ -354,7 +353,10 @@ jobs:
|
||||
matrix:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- *install_cosign
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -391,7 +393,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -405,7 +407,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
|
||||
uses: docker/setup-buildx-action@aa33708b10e362ff993539393ff100fa93ed6a27 # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -416,9 +418,19 @@ jobs:
|
||||
ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Copying ${arch} image to DockerHub..."
|
||||
docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
sleep 10
|
||||
if [ "${attempt}" -eq 3 ]; then
|
||||
echo "Failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
|
||||
done
|
||||
|
||||
@@ -464,7 +476,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
@@ -509,7 +521,7 @@ jobs:
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.1"
|
||||
HA_SHORT_VERSION: "2025.12"
|
||||
DEFAULT_PYTHON: "3.13.9"
|
||||
ALL_PYTHON_VERSIONS: "['3.13.9', '3.14.0']"
|
||||
# 10.3 is the oldest supported version
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Check out code from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
- name: Generate partial Python venv restore key
|
||||
id: generate_python_cache_key
|
||||
run: |
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -21,14 +21,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
- name: Detect duplicates using AI
|
||||
id: ai_detection
|
||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
uses: actions/ai-inference@5022b33bc1431add9b2831934daf8147a2ad9331 # v2.0.2
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt: |
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Detect language using AI
|
||||
id: ai_language_detection
|
||||
if: steps.detect_language.outputs.should_continue == 'true'
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
uses: actions/ai-inference@5022b33bc1431add9b2831934daf8147a2ad9331 # v2.0.2
|
||||
with:
|
||||
model: openai/gpt-4o-mini
|
||||
system-prompt: |
|
||||
|
||||
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
steps:
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
sed -i "/uv/d" requirements_diff.txt
|
||||
|
||||
- name: Build wheels
|
||||
uses: &home-assistant-wheels home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
|
||||
uses: &home-assistant-wheels home-assistant/wheels@6066c17a2a4aafcf7bdfeae01717f63adfcdba98 # 2025.11.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
3
CODEOWNERS
generated
3
CODEOWNERS
generated
@@ -539,8 +539,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/freebox/ @hacf-fr @Quentame
|
||||
/homeassistant/components/freedompro/ @stefano055415
|
||||
/tests/components/freedompro/ @stefano055415
|
||||
/homeassistant/components/fressnapf_tracker/ @eifinger
|
||||
/tests/components/fressnapf_tracker/ @eifinger
|
||||
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
|
||||
@@ -1763,7 +1761,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
/tests/components/vivotek/ @HarlemSquirrel
|
||||
/homeassistant/components/vizio/ @raman325
|
||||
/tests/components/vizio/ @raman325
|
||||
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare
|
||||
|
||||
@@ -35,22 +35,25 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
USER vscode
|
||||
|
||||
COPY .python-version ./
|
||||
RUN uv python install
|
||||
|
||||
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
|
||||
RUN --mount=type=bind,source=.python-version,target=.python-version \
|
||||
uv python install \
|
||||
&& uv venv $VIRTUAL_ENV
|
||||
RUN uv venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
# Setup hass-release
|
||||
RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \
|
||||
&& uv pip install -e ~/hass-release/
|
||||
|
||||
# Install Python dependencies from requirements
|
||||
RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
|
||||
--mount=type=bind,source=homeassistant/package_constraints.txt,target=homeassistant/package_constraints.txt \
|
||||
--mount=type=bind,source=requirements_test.txt,target=requirements_test.txt \
|
||||
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
COPY requirements.txt ./
|
||||
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
|
||||
RUN uv pip install -r requirements.txt
|
||||
COPY requirements_test.txt requirements_test_pre_commit.txt ./
|
||||
RUN uv pip install -r requirements_test.txt
|
||||
|
||||
WORKDIR /workspaces
|
||||
|
||||
|
||||
@@ -1000,7 +1000,7 @@ class _WatchPendingSetups:
|
||||
# We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done
|
||||
# once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up
|
||||
_LOGGER.warning(
|
||||
"Waiting for integrations to complete setup: %s",
|
||||
"Waiting on integrations to complete setup: %s",
|
||||
self._setup_started,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -175,56 +174,6 @@ class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
# Combine existing data with new password
|
||||
data = {
|
||||
CONF_HOST: reauth_entry.data[CONF_HOST],
|
||||
CONF_USERNAME: reauth_entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
|
||||
try:
|
||||
await validate_input(self.hass, data)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"username": reauth_entry.data[CONF_USERNAME],
|
||||
"host": reauth_entry.data[CONF_HOST],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
@@ -11,7 +11,6 @@ from pyairobotrest.exceptions import AirobotAuthError, AirobotConnectionError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -54,15 +53,7 @@ class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]):
|
||||
try:
|
||||
status = await self.client.get_statuses()
|
||||
settings = await self.client.get_settings()
|
||||
except AirobotAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from err
|
||||
except AirobotConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
) from err
|
||||
except (AirobotAuthError, AirobotConnectionError) as err:
|
||||
raise UpdateFailed(f"Failed to communicate with device: {err}") from err
|
||||
|
||||
return AirobotData(status=status, settings=settings)
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyairobotrest==0.1.0"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -15,24 +14,15 @@
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
|
||||
"password": "The thermostat password."
|
||||
},
|
||||
"description": "Airobot thermostat {device_id} discovered at {host}. Enter the password to complete setup. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::airobot::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "The authentication for Airobot thermostat at {host} (Device ID: {username}) has expired. Please enter the password to reauthenticate. Find the password in the thermostat settings menu under Connectivity → Mobile app."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "Device ID"
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Airobot thermostat.",
|
||||
@@ -44,12 +34,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed, please reauthenticate."
|
||||
},
|
||||
"connection_failed": {
|
||||
"message": "Failed to communicate with device."
|
||||
},
|
||||
"set_preset_mode_failed": {
|
||||
"message": "Failed to set preset mode to {preset_mode}."
|
||||
},
|
||||
|
||||
@@ -421,8 +421,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
|
||||
@@ -583,7 +583,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]),
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.75.0"]
|
||||
"requirements": ["anthropic==0.73.0"]
|
||||
}
|
||||
|
||||
@@ -17,12 +17,8 @@ from homeassistant.components.media_player import (
|
||||
class BangOlufsenSource:
|
||||
"""Class used for associating device source ids with friendly names. May not include all sources."""
|
||||
|
||||
DEEZER: Final[Source] = Source(name="Deezer", id="deezer")
|
||||
LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn")
|
||||
NET_RADIO: Final[Source] = Source(name="B&O Radio", id="netRadio")
|
||||
SPDIF: Final[Source] = Source(name="Optical", id="spdif")
|
||||
TIDAL: Final[Source] = Source(name="Tidal", id="tidal")
|
||||
UNKNOWN: Final[Source] = Source(name="Unknown Source", id="unknown")
|
||||
URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer")
|
||||
|
||||
|
||||
@@ -82,16 +78,6 @@ class BangOlufsenModel(StrEnum):
|
||||
BEOREMOTE_ONE = "Beoremote One"
|
||||
|
||||
|
||||
class BangOlufsenAttribute(StrEnum):
|
||||
"""Enum for extra_state_attribute keys."""
|
||||
|
||||
BEOLINK = "beolink"
|
||||
BEOLINK_PEERS = "peers"
|
||||
BEOLINK_SELF = "self"
|
||||
BEOLINK_LEADER = "leader"
|
||||
BEOLINK_LISTENERS = "listeners"
|
||||
|
||||
|
||||
# Physical "buttons" on devices
|
||||
class BangOlufsenButtons(StrEnum):
|
||||
"""Enum for device buttons."""
|
||||
|
||||
@@ -82,7 +82,6 @@ from .const import (
|
||||
FALLBACK_SOURCES,
|
||||
MANUFACTURER,
|
||||
VALID_MEDIA_TYPES,
|
||||
BangOlufsenAttribute,
|
||||
BangOlufsenMediaType,
|
||||
BangOlufsenSource,
|
||||
WebsocketNotification,
|
||||
@@ -225,8 +224,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
# Beolink compatible sources
|
||||
self._beolink_sources: dict[str, bool] = {}
|
||||
self._remote_leader: BeolinkLeader | None = None
|
||||
# Extra state attributes:
|
||||
# Beolink: peer(s), listener(s), leader and self
|
||||
# Extra state attributes for showing Beolink: peer(s), listener(s), leader and self
|
||||
self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -438,10 +436,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
await self._async_update_beolink()
|
||||
|
||||
async def _async_update_beolink(self) -> None:
|
||||
"""Update the current Beolink leader, listeners, peers and self.
|
||||
|
||||
Updates Home Assistant state.
|
||||
"""
|
||||
"""Update the current Beolink leader, listeners, peers and self."""
|
||||
|
||||
self._beolink_attributes = {}
|
||||
|
||||
@@ -450,24 +445,18 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
# Add Beolink self
|
||||
self._beolink_attributes = {
|
||||
BangOlufsenAttribute.BEOLINK: {
|
||||
BangOlufsenAttribute.BEOLINK_SELF: {
|
||||
self.device_entry.name: self._beolink_jid
|
||||
}
|
||||
}
|
||||
"beolink": {"self": {self.device_entry.name: self._beolink_jid}}
|
||||
}
|
||||
|
||||
# Add Beolink peers
|
||||
peers = await self._client.get_beolink_peers()
|
||||
|
||||
if len(peers) > 0:
|
||||
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
|
||||
BangOlufsenAttribute.BEOLINK_PEERS
|
||||
] = {}
|
||||
self._beolink_attributes["beolink"]["peers"] = {}
|
||||
for peer in peers:
|
||||
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
|
||||
BangOlufsenAttribute.BEOLINK_PEERS
|
||||
][peer.friendly_name] = peer.jid
|
||||
self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = (
|
||||
peer.jid
|
||||
)
|
||||
|
||||
# Add Beolink listeners / leader
|
||||
self._remote_leader = self._playback_metadata.remote_leader
|
||||
@@ -488,9 +477,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
# Add self
|
||||
group_members.append(self.entity_id)
|
||||
|
||||
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
|
||||
BangOlufsenAttribute.BEOLINK_LEADER
|
||||
] = {
|
||||
self._beolink_attributes["beolink"]["leader"] = {
|
||||
self._remote_leader.friendly_name: self._remote_leader.jid,
|
||||
}
|
||||
|
||||
@@ -527,9 +514,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
beolink_listener.jid
|
||||
)
|
||||
break
|
||||
self._beolink_attributes[BangOlufsenAttribute.BEOLINK][
|
||||
BangOlufsenAttribute.BEOLINK_LISTENERS
|
||||
] = beolink_listeners_attribute
|
||||
self._beolink_attributes["beolink"]["listeners"] = (
|
||||
beolink_listeners_attribute
|
||||
)
|
||||
|
||||
self._attr_group_members = group_members
|
||||
|
||||
@@ -628,18 +615,11 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_content_type(self) -> MediaType | str | None:
|
||||
def media_content_type(self) -> str:
|
||||
"""Return the current media type."""
|
||||
content_type = {
|
||||
BangOlufsenSource.URI_STREAMER.id: MediaType.URL,
|
||||
BangOlufsenSource.DEEZER.id: BangOlufsenMediaType.DEEZER,
|
||||
BangOlufsenSource.TIDAL.id: BangOlufsenMediaType.TIDAL,
|
||||
BangOlufsenSource.NET_RADIO.id: BangOlufsenMediaType.RADIO,
|
||||
}
|
||||
# Hard to determine content type.
|
||||
if self._source_change.id in content_type:
|
||||
return content_type[self._source_change.id]
|
||||
|
||||
# Hard to determine content type
|
||||
if self._source_change.id == BangOlufsenSource.URI_STREAMER.id:
|
||||
return MediaType.URL
|
||||
return MediaType.MUSIC
|
||||
|
||||
@property
|
||||
@@ -652,11 +632,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
"""Return the current playback progress."""
|
||||
return self._playback_progress.progress
|
||||
|
||||
@property
|
||||
def media_content_id(self) -> str | None:
|
||||
"""Return internal ID of Deezer, Tidal and radio stations."""
|
||||
return self._playback_metadata.source_internal_id
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Return URL of the currently playing music."""
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.8.0"
|
||||
"habluetooth==5.7.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ from typing import Any
|
||||
from pycoolmasternet_async import SWING_MODES
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
@@ -35,16 +31,7 @@ CM_TO_HA_STATE = {
|
||||
|
||||
HA_STATE_TO_CM = {value: key for key, value in CM_TO_HA_STATE.items()}
|
||||
|
||||
CM_TO_HA_FAN = {
|
||||
"low": FAN_LOW,
|
||||
"med": FAN_MEDIUM,
|
||||
"high": FAN_HIGH,
|
||||
"auto": FAN_AUTO,
|
||||
}
|
||||
|
||||
HA_FAN_TO_CM = {value: key for key, value in CM_TO_HA_FAN.items()}
|
||||
|
||||
FAN_MODES = list(CM_TO_HA_FAN.values())
|
||||
FAN_MODES = ["low", "med", "high", "auto"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -124,7 +111,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
@property
|
||||
def fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
return CM_TO_HA_FAN[self._unit.fan_speed]
|
||||
return self._unit.fan_speed
|
||||
|
||||
@property
|
||||
def fan_modes(self):
|
||||
@@ -151,7 +138,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity):
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new fan mode."""
|
||||
_LOGGER.debug("Setting fan mode of %s to %s", self.unique_id, fan_mode)
|
||||
self._unit = await self._unit.set_fan_speed(HA_FAN_TO_CM[fan_mode])
|
||||
self._unit = await self._unit.set_fan_speed(fan_mode)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==16.4.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ DEFAULT_TTS_MODEL = "eleven_multilingual_v2"
|
||||
DEFAULT_STABILITY = 0.5
|
||||
DEFAULT_SIMILARITY = 0.75
|
||||
DEFAULT_STT_AUTO_LANGUAGE = False
|
||||
DEFAULT_STT_MODEL = "scribe_v2"
|
||||
DEFAULT_STT_MODEL = "scribe_v1"
|
||||
DEFAULT_STYLE = 0
|
||||
DEFAULT_USE_SPEAKER_BOOST = True
|
||||
|
||||
@@ -129,5 +129,4 @@ STT_LANGUAGES = [
|
||||
STT_MODELS = {
|
||||
"scribe_v1": "Scribe v1",
|
||||
"scribe_v1_experimental": "Scribe v1 Experimental",
|
||||
"scribe_v2": "Scribe v2 Realtime",
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==42.10.0",
|
||||
"aioesphomeapi==42.9.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
"""API for fitbit bound to Home Assistant OAuth."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from fitbit import Fitbit
|
||||
from fitbit.exceptions import HTTPException, HTTPUnauthorized
|
||||
from fitbit_web_api import ApiClient, Configuration, DevicesApi
|
||||
from fitbit_web_api.exceptions import (
|
||||
ApiException,
|
||||
OpenApiException,
|
||||
UnauthorizedException,
|
||||
)
|
||||
from fitbit_web_api.models.device import Device
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import FitbitUnitSystem
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import FitbitProfile
|
||||
from .model import FitbitDevice, FitbitProfile
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,14 +58,6 @@ class FitbitApi(ABC):
|
||||
expires_at=float(token[CONF_EXPIRES_AT]),
|
||||
)
|
||||
|
||||
async def _async_get_fitbit_web_api(self) -> ApiClient:
|
||||
"""Create and return an ApiClient configured with the current access token."""
|
||||
token = await self.async_get_access_token()
|
||||
configuration = Configuration()
|
||||
configuration.pool_manager = async_get_clientsession(self._hass)
|
||||
configuration.access_token = token[CONF_ACCESS_TOKEN]
|
||||
return ApiClient(configuration)
|
||||
|
||||
async def async_get_user_profile(self) -> FitbitProfile:
|
||||
"""Return the user profile from the API."""
|
||||
if self._profile is None:
|
||||
@@ -110,13 +94,21 @@ class FitbitApi(ABC):
|
||||
return FitbitUnitSystem.METRIC
|
||||
return FitbitUnitSystem.EN_US
|
||||
|
||||
async def async_get_devices(self) -> list[Device]:
|
||||
"""Return available devices using fitbit-web-api."""
|
||||
client = await self._async_get_fitbit_web_api()
|
||||
devices_api = DevicesApi(client)
|
||||
devices: list[Device] = await self._run_async(devices_api.get_devices)
|
||||
async def async_get_devices(self) -> list[FitbitDevice]:
|
||||
"""Return available devices."""
|
||||
client = await self._async_get_client()
|
||||
devices: list[dict[str, str]] = await self._run(client.get_devices)
|
||||
_LOGGER.debug("get_devices=%s", devices)
|
||||
return devices
|
||||
return [
|
||||
FitbitDevice(
|
||||
id=device["id"],
|
||||
device_version=device["deviceVersion"],
|
||||
battery_level=int(device["batteryLevel"]),
|
||||
battery=device["battery"],
|
||||
type=device["type"],
|
||||
)
|
||||
for device in devices
|
||||
]
|
||||
|
||||
async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]:
|
||||
"""Return the most recent value from the time series for the specified resource type."""
|
||||
@@ -148,20 +140,6 @@ class FitbitApi(ABC):
|
||||
_LOGGER.debug("Error from fitbit API: %s", err)
|
||||
raise FitbitApiException("Error from fitbit API") from err
|
||||
|
||||
async def _run_async[_T](self, func: Callable[[], Awaitable[_T]]) -> _T:
|
||||
"""Run client command."""
|
||||
try:
|
||||
return await func()
|
||||
except UnauthorizedException as err:
|
||||
_LOGGER.debug("Unauthorized error from fitbit API: %s", err)
|
||||
raise FitbitAuthException("Authentication error from fitbit API") from err
|
||||
except ApiException as err:
|
||||
_LOGGER.debug("Error from fitbit API: %s", err)
|
||||
raise FitbitApiException("Error from fitbit API") from err
|
||||
except OpenApiException as err:
|
||||
_LOGGER.debug("Error communicating with fitbit API: %s", err)
|
||||
raise FitbitApiException("Communication error from fitbit API") from err
|
||||
|
||||
|
||||
class OAuthFitbitApi(FitbitApi):
|
||||
"""Provide fitbit authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
@@ -6,8 +6,6 @@ import datetime
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from fitbit_web_api.models.device import Device
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
@@ -15,6 +13,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .api import FitbitApi
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import FitbitDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,7 +23,7 @@ TIMEOUT = 10
|
||||
type FitbitConfigEntry = ConfigEntry[FitbitData]
|
||||
|
||||
|
||||
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]):
|
||||
"""Coordinator for fetching fitbit devices from the API."""
|
||||
|
||||
config_entry: FitbitConfigEntry
|
||||
@@ -42,7 +41,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
)
|
||||
self._api = api
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Device]:
|
||||
async def _async_update_data(self) -> dict[str, FitbitDevice]:
|
||||
"""Fetch data from API endpoint."""
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
try:
|
||||
@@ -51,7 +50,7 @@ class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
except FitbitApiException as err:
|
||||
raise UpdateFailed(err) from err
|
||||
return {device.id: device for device in devices if device.id is not None}
|
||||
return {device.id: device for device in devices}
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"dependencies": ["application_credentials", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/fitbit",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["fitbit", "fitbit_web_api"],
|
||||
"requirements": ["fitbit==0.3.1", "fitbit-web-api==2.13.5"]
|
||||
"loggers": ["fitbit"],
|
||||
"requirements": ["fitbit==0.3.1"]
|
||||
}
|
||||
|
||||
@@ -21,6 +21,26 @@ class FitbitProfile:
|
||||
"""The locale defined in the user's Fitbit account settings."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitDevice:
|
||||
"""Device from the Fitbit API response."""
|
||||
|
||||
id: str
|
||||
"""The device ID."""
|
||||
|
||||
device_version: str
|
||||
"""The product name of the device."""
|
||||
|
||||
battery_level: int
|
||||
"""The battery level as a percentage."""
|
||||
|
||||
battery: str
|
||||
"""Returns the battery level of the device."""
|
||||
|
||||
type: str
|
||||
"""The type of the device such as TRACKER or SCALE."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FitbitConfig:
|
||||
"""Information from the fitbit ConfigEntry data."""
|
||||
|
||||
@@ -8,8 +8,6 @@ import datetime
|
||||
import logging
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from fitbit_web_api.models.device import Device
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -34,7 +32,7 @@ from .api import FitbitApi
|
||||
from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem
|
||||
from .coordinator import FitbitConfigEntry, FitbitDeviceCoordinator
|
||||
from .exceptions import FitbitApiException, FitbitAuthException
|
||||
from .model import config_from_entry_data
|
||||
from .model import FitbitDevice, config_from_entry_data
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
@@ -659,7 +657,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
coordinator: FitbitDeviceCoordinator,
|
||||
user_profile_id: str,
|
||||
description: FitbitSensorEntityDescription,
|
||||
device: Device,
|
||||
device: FitbitDevice,
|
||||
enable_default_override: bool,
|
||||
) -> None:
|
||||
"""Initialize the Fitbit sensor."""
|
||||
@@ -679,9 +677,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Icon to use in the frontend, if any."""
|
||||
if self.device.battery is not None and (
|
||||
battery_level := BATTERY_LEVELS.get(self.device.battery)
|
||||
):
|
||||
if battery_level := BATTERY_LEVELS.get(self.device.battery):
|
||||
return icon_for_battery_level(battery_level=battery_level)
|
||||
return self.entity_description.icon
|
||||
|
||||
@@ -701,7 +697,7 @@ class FitbitBatterySensor(CoordinatorEntity[FitbitDeviceCoordinator], SensorEnti
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.device = self.coordinator.data[cast(str, self.device.id)]
|
||||
self.device = self.coordinator.data[self.device.id]
|
||||
self._attr_native_value = self.device.battery
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -719,7 +715,7 @@ class FitbitBatteryLevelSensor(
|
||||
coordinator: FitbitDeviceCoordinator,
|
||||
user_profile_id: str,
|
||||
description: FitbitSensorEntityDescription,
|
||||
device: Device,
|
||||
device: FitbitDevice,
|
||||
) -> None:
|
||||
"""Initialize the Fitbit sensor."""
|
||||
super().__init__(coordinator)
|
||||
@@ -740,6 +736,6 @@ class FitbitBatteryLevelSensor(
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.device = self.coordinator.data[cast(str, self.device.id)]
|
||||
self.device = self.coordinator.data[self.device.id]
|
||||
self._attr_native_value = self.device.battery_level
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""The Fressnapf Tracker integration."""
|
||||
|
||||
from fressnapftracker import AuthClient
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_USER_ID
|
||||
from .coordinator import (
|
||||
FressnapfTrackerConfigEntry,
|
||||
FressnapfTrackerDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Fressnapf Tracker from a config entry."""
|
||||
auth_client = AuthClient(client=get_async_client(hass))
|
||||
devices = await auth_client.get_devices(
|
||||
user_id=entry.data[CONF_USER_ID],
|
||||
user_access_token=entry.data[CONF_ACCESS_TOKEN],
|
||||
)
|
||||
|
||||
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
|
||||
for device in devices:
|
||||
coordinator = FressnapfTrackerDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
device,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
coordinators.append(coordinator)
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Binary Sensor platform for fressnapf_tracker."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fressnapftracker import Tracker
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .entity import FressnapfTrackerEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FressnapfTrackerBinarySensorDescription(BinarySensorEntityDescription):
|
||||
"""Class describing Fressnapf Tracker binary_sensor entities."""
|
||||
|
||||
value_fn: Callable[[Tracker], bool]
|
||||
|
||||
|
||||
BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[
|
||||
FressnapfTrackerBinarySensorDescription, ...
|
||||
] = (
|
||||
FressnapfTrackerBinarySensorDescription(
|
||||
key="charging",
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.charging,
|
||||
),
|
||||
FressnapfTrackerBinarySensorDescription(
|
||||
translation_key="deep_sleep",
|
||||
key="deep_sleep_value",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: bool(data.deep_sleep_value),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FressnapfTrackerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Fressnapf Tracker binary_sensors."""
|
||||
|
||||
async_add_entities(
|
||||
FressnapfTrackerBinarySensor(coordinator, sensor_description)
|
||||
for sensor_description in BINARY_SENSOR_ENTITY_DESCRIPTIONS
|
||||
for coordinator in entry.runtime_data
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerBinarySensor(FressnapfTrackerEntity, BinarySensorEntity):
|
||||
"""Fressnapf Tracker binary_sensor for general information."""
|
||||
|
||||
entity_description: FressnapfTrackerBinarySensorDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Config flow for the Fressnapf Tracker integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fressnapftracker import (
|
||||
AuthClient,
|
||||
FressnapfTrackerInvalidPhoneNumberError,
|
||||
FressnapfTrackerInvalidTokenError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import CONF_PHONE_NUMBER, CONF_SMS_CODE, CONF_USER_ID, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PHONE_NUMBER): str,
|
||||
}
|
||||
)
|
||||
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SMS_CODE): int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fressnapf Tracker."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Init Config Flow."""
|
||||
self._context: dict[str, Any] = {}
|
||||
self._auth_client: AuthClient | None = None
|
||||
|
||||
@property
|
||||
def auth_client(self) -> AuthClient:
|
||||
"""Return the auth client, creating it if needed."""
|
||||
if self._auth_client is None:
|
||||
self._auth_client = AuthClient(client=get_async_client(self.hass))
|
||||
return self._auth_client
|
||||
|
||||
async def _async_request_sms_code(
|
||||
self, phone_number: str
|
||||
) -> tuple[dict[str, str], bool]:
|
||||
"""Request SMS code and return errors dict and success flag."""
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
response = await self.auth_client.request_sms_code(
|
||||
phone_number=phone_number
|
||||
)
|
||||
except FressnapfTrackerInvalidPhoneNumberError:
|
||||
errors["base"] = "invalid_phone_number"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
_LOGGER.debug("SMS code request response: %s", response)
|
||||
self._context[CONF_USER_ID] = response.id
|
||||
self._context[CONF_PHONE_NUMBER] = phone_number
|
||||
return errors, True
|
||||
return errors, False
|
||||
|
||||
async def _async_verify_sms_code(
|
||||
self, sms_code: int
|
||||
) -> tuple[dict[str, str], str | None]:
|
||||
"""Verify SMS code and return errors and access_token."""
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
verification_response = await self.auth_client.verify_phone_number(
|
||||
user_id=self._context[CONF_USER_ID],
|
||||
sms_code=sms_code,
|
||||
)
|
||||
except FressnapfTrackerInvalidTokenError:
|
||||
errors["base"] = "invalid_sms_code"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during SMS code verification")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Phone number verification response: %s", verification_response
|
||||
)
|
||||
return errors, verification_response.user_token.access_token
|
||||
return errors, None
|
||||
|
||||
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_PHONE_NUMBER: user_input[CONF_PHONE_NUMBER]}
|
||||
)
|
||||
errors, success = await self._async_request_sms_code(
|
||||
user_input[CONF_PHONE_NUMBER]
|
||||
)
|
||||
if success:
|
||||
await self.async_set_unique_id(str(self._context[CONF_USER_ID]))
|
||||
self._abort_if_unique_id_configured()
|
||||
return await self.async_step_sms_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_sms_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the SMS code step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
errors, access_token = await self._async_verify_sms_code(
|
||||
user_input[CONF_SMS_CODE]
|
||||
)
|
||||
if access_token:
|
||||
return self.async_create_entry(
|
||||
title=self._context[CONF_PHONE_NUMBER],
|
||||
data={
|
||||
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
|
||||
CONF_USER_ID: self._context[CONF_USER_ID],
|
||||
CONF_ACCESS_TOKEN: access_token,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="sms_code",
|
||||
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
errors, success = await self._async_request_sms_code(
|
||||
user_input[CONF_PHONE_NUMBER]
|
||||
)
|
||||
if success:
|
||||
if reconfigure_entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
|
||||
errors["base"] = "account_change_not_allowed"
|
||||
else:
|
||||
return await self.async_step_reconfigure_sms_code()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PHONE_NUMBER,
|
||||
default=reconfigure_entry.data.get(CONF_PHONE_NUMBER),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure_sms_code(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the SMS code step during reconfiguration."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
errors, access_token = await self._async_verify_sms_code(
|
||||
user_input[CONF_SMS_CODE]
|
||||
)
|
||||
if access_token:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data={
|
||||
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
|
||||
CONF_USER_ID: self._context[CONF_USER_ID],
|
||||
CONF_ACCESS_TOKEN: access_token,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure_sms_code",
|
||||
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Constants for the Fressnapf Tracker integration."""
|
||||
|
||||
DOMAIN = "fressnapf_tracker"
|
||||
CONF_PHONE_NUMBER = "phone_number"
|
||||
CONF_SMS_CODE = "sms_code"
|
||||
CONF_USER_ID = "user_id"
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Data update coordinator for Fressnapf Tracker integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from fressnapftracker import ApiClient, Device, FressnapfTrackerError, Tracker
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type FressnapfTrackerConfigEntry = ConfigEntry[
|
||||
list[FressnapfTrackerDataUpdateCoordinator]
|
||||
]
|
||||
|
||||
|
||||
class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: FressnapfTrackerConfigEntry,
|
||||
device: Device,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=15),
|
||||
config_entry=config_entry,
|
||||
)
|
||||
self.device = device
|
||||
self.client = ApiClient(
|
||||
serial_number=device.serialnumber,
|
||||
device_token=device.token,
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> Tracker:
|
||||
try:
|
||||
return await self.client.get_tracker()
|
||||
except FressnapfTrackerError as exception:
|
||||
raise UpdateFailed(exception) from exception
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Device tracker platform for fressnapf_tracker."""
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType
|
||||
from homeassistant.components.device_tracker.config_entry import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry, FressnapfTrackerDataUpdateCoordinator
|
||||
from .entity import FressnapfTrackerBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FressnapfTrackerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the fressnapf_tracker device_trackers."""
|
||||
async_add_entities(
|
||||
FressnapfTrackerDeviceTracker(coordinator) for coordinator in entry.runtime_data
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
|
||||
"""fressnapf_tracker device tracker."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_translation_key = "pet"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FressnapfTrackerDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the device tracker."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = coordinator.device.serialnumber
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.coordinator.data.position is not None
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
if self.coordinator.data.position is not None:
|
||||
return self.coordinator.data.position.lat
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
if self.coordinator.data.position is not None:
|
||||
return self.coordinator.data.position.lng
|
||||
return None
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type, eg gps or router, of the device."""
|
||||
return SourceType.GPS
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy of the device.
|
||||
|
||||
Value in meters.
|
||||
"""
|
||||
if self.coordinator.data.position is not None:
|
||||
return float(self.coordinator.data.position.accuracy)
|
||||
return 0
|
||||
@@ -1,42 +0,0 @@
|
||||
"""fressnapf_tracker class."""
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import FressnapfTrackerDataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class FressnapfTrackerBaseEntity(
|
||||
CoordinatorEntity[FressnapfTrackerDataUpdateCoordinator]
|
||||
):
|
||||
"""Base entity for Fressnapf Tracker."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: FressnapfTrackerDataUpdateCoordinator) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self.id = coordinator.device.serialnumber
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(self.id))},
|
||||
name=str(self.coordinator.data.name),
|
||||
model=str(self.coordinator.data.tracker_settings.generation),
|
||||
manufacturer="Fressnapf",
|
||||
serial_number=str(self.id),
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerEntity(FressnapfTrackerBaseEntity):
|
||||
"""Entity for fressnapf_tracker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FressnapfTrackerDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{self.id}_{entity_description.key}"
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"device_tracker": {
|
||||
"pet": {
|
||||
"default": "mdi:paw"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "fressnapf_tracker",
|
||||
"name": "Fressnapf Tracker",
|
||||
"codeowners": ["@eifinger"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fressnapf_tracker",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fressnapftracker==0.1.2"]
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Sensor platform for fressnapf_tracker."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fressnapftracker import Tracker
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FressnapfTrackerConfigEntry
|
||||
from .entity import FressnapfTrackerEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FressnapfTrackerSensorDescription(SensorEntityDescription):
|
||||
"""Class describing Fressnapf Tracker sensor entities."""
|
||||
|
||||
value_fn: Callable[[Tracker], int]
|
||||
|
||||
|
||||
SENSOR_ENTITY_DESCRIPTIONS: tuple[FressnapfTrackerSensorDescription, ...] = (
|
||||
FressnapfTrackerSensorDescription(
|
||||
key="battery",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.battery,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FressnapfTrackerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Fressnapf Tracker sensors."""
|
||||
|
||||
async_add_entities(
|
||||
FressnapfTrackerSensor(coordinator, sensor_description)
|
||||
for sensor_description in SENSOR_ENTITY_DESCRIPTIONS
|
||||
for coordinator in entry.runtime_data
|
||||
)
|
||||
|
||||
|
||||
class FressnapfTrackerSensor(FressnapfTrackerEntity, SensorEntity):
|
||||
"""fressnapf_tracker sensor for general information."""
|
||||
|
||||
entity_description: FressnapfTrackerSensorDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
"""Return the state of the resources if it has been received yet."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"account_change_not_allowed": "Reconfiguring to a different account is not allowed. Please create a new entry instead.",
|
||||
"invalid_phone_number": "Please enter a valid phone number.",
|
||||
"invalid_sms_code": "The SMS code you entered is invalid.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data::phone_number%]"
|
||||
},
|
||||
"data_description": {
|
||||
"phone_number": "[%key:component::fressnapf_tracker::config::step::user::data_description::phone_number%]"
|
||||
},
|
||||
"description": "Re-authenticate with your Fressnapf Tracker account to refresh your credentials."
|
||||
},
|
||||
"reconfigure_sms_code": {
|
||||
"data": {
|
||||
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data::sms_code%]"
|
||||
},
|
||||
"data_description": {
|
||||
"sms_code": "[%key:component::fressnapf_tracker::config::step::sms_code::data_description::sms_code%]"
|
||||
}
|
||||
},
|
||||
"sms_code": {
|
||||
"data": {
|
||||
"sms_code": "SMS code"
|
||||
},
|
||||
"data_description": {
|
||||
"sms_code": "Enter the SMS code you received on your phone."
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"phone_number": "Phone number"
|
||||
},
|
||||
"data_description": {
|
||||
"phone_number": "Enter your phone number in international format (e.g., +4917612345678)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"deep_sleep": {
|
||||
"name": "Deep sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,5 +23,5 @@
|
||||
"winter_mode": {}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251202.0"]
|
||||
"requirements": ["home-assistant-frontend==20251203.0"]
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ from homeassistant.helpers.issue_registry import (
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
|
||||
from .const import CONF_IGNORE_NON_NUMERIC, DOMAIN
|
||||
from .entity import GroupEntity
|
||||
@@ -374,7 +374,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
def async_update_group_state(self) -> None:
|
||||
"""Query all members and determine the sensor group state."""
|
||||
self.calculate_state_attributes(self._get_valid_entities())
|
||||
states: list[str] = []
|
||||
states: list[StateType] = []
|
||||
valid_units = self._valid_units
|
||||
valid_states: list[bool] = []
|
||||
sensor_values: list[tuple[str, float, State]] = []
|
||||
|
||||
@@ -37,6 +37,7 @@ def get_device_list_classic(
|
||||
login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD])
|
||||
# DEBUG: Log the actual response structure
|
||||
except Exception as ex:
|
||||
_LOGGER.error("DEBUG - Login response: %s", login_response)
|
||||
raise ConfigEntryError(
|
||||
f"Error communicating with Growatt API during login: {ex}"
|
||||
) from ex
|
||||
|
||||
@@ -113,6 +113,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
min_settings = self.api.min_settings(self.device_id)
|
||||
min_energy = self.api.min_energy(self.device_id)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
_LOGGER.error(
|
||||
"Error fetching min device data for %s: %s", self.device_id, err
|
||||
)
|
||||
raise UpdateFailed(f"Error fetching min device data: {err}") from err
|
||||
|
||||
min_info = {**min_details, **min_settings, **min_energy}
|
||||
@@ -177,6 +180,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(self._sync_update_data)
|
||||
except json.decoder.JSONDecodeError as err:
|
||||
_LOGGER.error("Unable to fetch data from Growatt server: %s", err)
|
||||
raise UpdateFailed(f"Error fetching data: {err}") from err
|
||||
|
||||
def get_currency(self):
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: data-descriptions missing
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: todo
|
||||
comment: Update server URL dropdown to show regional descriptions (e.g., 'China', 'United States') instead of raw URLs.
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable:
|
||||
status: todo
|
||||
comment: Replace bare Exception catches in __init__.py with specific growattServer exceptions.
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: todo
|
||||
comment: Add serial_number field to DeviceInfo in sensor, number, and switch platforms using device_id/serial_id.
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: todo
|
||||
comment: Add EntityCategory.DIAGNOSTIC to temperature and other diagnostic sensors. Merge GrowattRequiredKeysMixin into GrowattSensorEntityDescription using kw_only=True.
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not raise repairable issues.
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -211,7 +211,7 @@ async def ws_start_preview(
|
||||
|
||||
@callback
|
||||
def async_preview_updated(
|
||||
last_exception: BaseException | None, state: str, attributes: Mapping[str, Any]
|
||||
last_exception: Exception | None, state: str, attributes: Mapping[str, Any]
|
||||
) -> None:
|
||||
"""Forward config entry state events to websocket."""
|
||||
if last_exception:
|
||||
|
||||
@@ -241,9 +241,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
|
||||
|
||||
async def async_start_preview(
|
||||
self,
|
||||
preview_callback: Callable[
|
||||
[BaseException | None, str, Mapping[str, Any]], None
|
||||
],
|
||||
preview_callback: Callable[[Exception | None, str, Mapping[str, Any]], None],
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Render a preview."""
|
||||
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.24.0"],
|
||||
"requirements": ["aiohomeconnect==0.23.1"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -94,8 +94,6 @@ SERVICE_KNX_EVENT_REGISTER: Final = "event_register"
|
||||
SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register"
|
||||
SERVICE_KNX_READ: Final = "read"
|
||||
|
||||
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: Final = "data_secure_group_key_issue"
|
||||
|
||||
|
||||
class KNXConfigEntryData(TypedDict, total=False):
|
||||
"""Config entry for the KNX integration."""
|
||||
@@ -164,11 +162,8 @@ SUPPORTED_PLATFORMS_UI: Final = {
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.DATE,
|
||||
Platform.DATETIME,
|
||||
Platform.LIGHT,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
}
|
||||
|
||||
# Map KNX controller modes to HA modes. This list might not be complete.
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as dt_date
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import DateDevice as XknxDateDevice
|
||||
from xknx.dpt.dpt_11 import KNXDate as XKNXDate
|
||||
|
||||
@@ -18,10 +18,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -29,14 +26,11 @@ from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
CONF_SYNC_STATE,
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
KNX_MODULE_KEY,
|
||||
)
|
||||
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .entity import KnxYamlEntity
|
||||
from .knx_module import KNXModule
|
||||
from .storage.const import CONF_ENTITY, CONF_GA_DATE
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -46,36 +40,40 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up entities for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.DATE,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiDate,
|
||||
),
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.DATE]
|
||||
|
||||
async_add_entities(
|
||||
KNXDateEntity(knx_module, entity_config) for entity_config in config
|
||||
)
|
||||
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.DATE):
|
||||
entities.extend(
|
||||
KnxYamlDate(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.DATE):
|
||||
entities.extend(
|
||||
KnxUiDate(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateDevice:
|
||||
"""Return a XKNX DateTime object to be used within XKNX."""
|
||||
return XknxDateDevice(
|
||||
xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
)
|
||||
|
||||
|
||||
class _KNXDate(DateEntity, RestoreEntity):
|
||||
class KNXDateEntity(KnxYamlEntity, DateEntity, RestoreEntity):
|
||||
"""Representation of a KNX date."""
|
||||
|
||||
_device: XknxDateDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_xknx_device(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -96,52 +94,3 @@ class _KNXDate(DateEntity, RestoreEntity):
|
||||
async def async_set_value(self, value: dt_date) -> None:
|
||||
"""Change the value."""
|
||||
await self._device.set(value)
|
||||
|
||||
|
||||
class KnxYamlDate(_KNXDate, KnxYamlEntity):
|
||||
"""Representation of a KNX date configured from YAML."""
|
||||
|
||||
_device: XknxDateDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX date."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxDateDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
class KnxUiDate(_KNXDate, KnxUiEntity):
|
||||
"""Representation of a KNX date configured from the UI."""
|
||||
|
||||
_device: XknxDateDevice
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize KNX date."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
self._device = XknxDateDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=knx_conf.get_write(CONF_GA_DATE),
|
||||
group_address_state=knx_conf.get_state_and_passive(CONF_GA_DATE),
|
||||
respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import DateTimeDevice as XknxDateTimeDevice
|
||||
from xknx.dpt.dpt_19 import KNXDateTime as XKNXDateTime
|
||||
|
||||
@@ -18,10 +18,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -30,14 +27,11 @@ from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
CONF_SYNC_STATE,
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
KNX_MODULE_KEY,
|
||||
)
|
||||
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .entity import KnxYamlEntity
|
||||
from .knx_module import KNXModule
|
||||
from .storage.const import CONF_ENTITY, CONF_GA_DATETIME
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -47,36 +41,40 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up entities for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.DATETIME,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiDateTime,
|
||||
),
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.DATETIME]
|
||||
|
||||
async_add_entities(
|
||||
KNXDateTimeEntity(knx_module, entity_config) for entity_config in config
|
||||
)
|
||||
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.DATETIME):
|
||||
entities.extend(
|
||||
KnxYamlDateTime(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.DATETIME):
|
||||
entities.extend(
|
||||
KnxUiDateTime(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTimeDevice:
|
||||
"""Return a XKNX DateTime object to be used within XKNX."""
|
||||
return XknxDateTimeDevice(
|
||||
xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
)
|
||||
|
||||
|
||||
class _KNXDateTime(DateTimeEntity, RestoreEntity):
|
||||
class KNXDateTimeEntity(KnxYamlEntity, DateTimeEntity, RestoreEntity):
|
||||
"""Representation of a KNX datetime."""
|
||||
|
||||
_device: XknxDateTimeDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_xknx_device(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -101,52 +99,3 @@ class _KNXDateTime(DateTimeEntity, RestoreEntity):
|
||||
async def async_set_value(self, value: datetime) -> None:
|
||||
"""Change the value."""
|
||||
await self._device.set(value.astimezone(dt_util.get_default_time_zone()))
|
||||
|
||||
|
||||
class KnxYamlDateTime(_KNXDateTime, KnxYamlEntity):
|
||||
"""Representation of a KNX datetime configured from YAML."""
|
||||
|
||||
_device: XknxDateTimeDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX datetime."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxDateTimeDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
class KnxUiDateTime(_KNXDateTime, KnxUiEntity):
|
||||
"""Representation of a KNX datetime configured from the UI."""
|
||||
|
||||
_device: XknxDateTimeDevice
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize KNX datetime."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
self._device = XknxDateTimeDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=knx_conf.get_write(CONF_GA_DATETIME),
|
||||
group_address_state=knx_conf.get_state_and_passive(CONF_GA_DATETIME),
|
||||
respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
)
|
||||
|
||||
@@ -77,11 +77,6 @@ class _KnxEntityBase(Entity):
|
||||
"""Store register state change callback and start device object."""
|
||||
self._device.register_device_updated_cb(self.after_update_callback)
|
||||
self._device.xknx.devices.async_add(self._device)
|
||||
if uid := self.unique_id:
|
||||
self._knx_module.add_to_group_address_entities(
|
||||
group_addresses=self._device.group_addresses(),
|
||||
identifier=(self.platform_data.domain, uid),
|
||||
)
|
||||
# super call needed to have methods of multi-inherited classes called
|
||||
# eg. for restoring state (like _KNXSwitch)
|
||||
await super().async_added_to_hass()
|
||||
@@ -90,11 +85,6 @@ class _KnxEntityBase(Entity):
|
||||
"""Disconnect device object when removed."""
|
||||
self._device.unregister_device_updated_cb(self.after_update_callback)
|
||||
self._device.xknx.devices.async_remove(self._device)
|
||||
if uid := self.unique_id:
|
||||
self._knx_module.remove_from_group_address_entities(
|
||||
group_addresses=self._device.group_addresses(),
|
||||
identifier=(self.platform_data.domain, uid),
|
||||
)
|
||||
|
||||
|
||||
class KnxYamlEntity(_KnxEntityBase):
|
||||
|
||||
@@ -56,7 +56,6 @@ from .const import (
|
||||
from .device import KNXInterfaceDevice
|
||||
from .expose import KNXExposeSensor, KNXExposeTime
|
||||
from .project import KNXProject
|
||||
from .repairs import data_secure_group_key_issue_dispatcher
|
||||
from .storage.config_store import KNXConfigStore
|
||||
from .telegrams import Telegrams
|
||||
|
||||
@@ -108,12 +107,8 @@ class KNXModule:
|
||||
|
||||
self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {}
|
||||
self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {}
|
||||
self.group_address_entities: dict[
|
||||
DeviceGroupAddress, set[tuple[str, str]] # {(platform, unique_id),}
|
||||
] = {}
|
||||
self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback()
|
||||
|
||||
self.entry.async_on_unload(data_secure_group_key_issue_dispatcher(self))
|
||||
self.entry.async_on_unload(
|
||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
|
||||
)
|
||||
@@ -230,29 +225,6 @@ class KNXModule:
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def add_to_group_address_entities(
|
||||
self,
|
||||
group_addresses: set[DeviceGroupAddress],
|
||||
identifier: tuple[str, str], # (platform, unique_id)
|
||||
) -> None:
|
||||
"""Register entity in group_address_entities map."""
|
||||
for ga in group_addresses:
|
||||
if ga not in self.group_address_entities:
|
||||
self.group_address_entities[ga] = set()
|
||||
self.group_address_entities[ga].add(identifier)
|
||||
|
||||
def remove_from_group_address_entities(
|
||||
self,
|
||||
group_addresses: set[DeviceGroupAddress],
|
||||
identifier: tuple[str, str],
|
||||
) -> None:
|
||||
"""Unregister entity from group_address_entities map."""
|
||||
for ga in group_addresses:
|
||||
if ga in self.group_address_entities:
|
||||
self.group_address_entities[ga].discard(identifier)
|
||||
if not self.group_address_entities[ga]:
|
||||
del self.group_address_entities[ga]
|
||||
|
||||
def connection_state_changed_cb(self, state: XknxConnectionState) -> None:
|
||||
"""Call invoked after a KNX connection state change was received."""
|
||||
self.connected = state == XknxConnectionState.CONNECTED
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"loggers": ["xknx", "xknxproject"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"xknx==3.12.0",
|
||||
"xknx==3.11.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.10.31.195356"
|
||||
],
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Repairs for KNX integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import voluptuous as vol
|
||||
from xknx.exceptions.exception import InvalidSecureConfiguration
|
||||
from xknx.telegram import GroupAddress, IndividualAddress, Telegram
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir, selector
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .knx_module import KNXModule
|
||||
|
||||
from .const import (
|
||||
CONF_KNX_KNXKEY_PASSWORD,
|
||||
DOMAIN,
|
||||
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
|
||||
KNXConfigEntryData,
|
||||
)
|
||||
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
|
||||
from .telegrams import SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, TelegramDict
|
||||
|
||||
CONF_KEYRING_FILE: Final = "knxkeys_file"
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
if issue_id == REPAIR_ISSUE_DATA_SECURE_GROUP_KEY:
|
||||
return DataSecureGroupIssueRepairFlow()
|
||||
# If KNX adds confirm-only repairs in the future, this should be changed
|
||||
# to return a ConfirmRepairFlow instead of raising a ValueError
|
||||
raise ValueError(f"unknown repair {issue_id}")
|
||||
|
||||
|
||||
######################
|
||||
# DataSecure key issue
|
||||
######################
|
||||
|
||||
|
||||
@callback
|
||||
def data_secure_group_key_issue_dispatcher(knx_module: KNXModule) -> Callable[[], None]:
|
||||
"""Watcher for DataSecure group key issues."""
|
||||
return async_dispatcher_connect(
|
||||
knx_module.hass,
|
||||
signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
|
||||
target=partial(_data_secure_group_key_issue_handler, knx_module),
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _data_secure_group_key_issue_handler(
|
||||
knx_module: KNXModule, telegram: Telegram, telegram_dict: TelegramDict
|
||||
) -> None:
|
||||
"""Handle DataSecure group key issue telegrams."""
|
||||
if telegram.destination_address not in knx_module.group_address_entities:
|
||||
# Only report issues for configured group addresses
|
||||
return
|
||||
|
||||
issue_registry = ir.async_get(knx_module.hass)
|
||||
new_ga = str(telegram.destination_address)
|
||||
new_ia = str(telegram.source_address)
|
||||
new_data = {new_ga: new_ia}
|
||||
|
||||
if existing_issue := issue_registry.async_get_issue(
|
||||
DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY
|
||||
):
|
||||
assert isinstance(existing_issue.data, dict)
|
||||
existing_data: dict[str, str] = existing_issue.data # type: ignore[assignment]
|
||||
if new_ga in existing_data:
|
||||
current_ias = existing_data[new_ga].split(", ")
|
||||
if new_ia in current_ias:
|
||||
return
|
||||
current_ias = sorted([*current_ias, new_ia], key=IndividualAddress)
|
||||
new_data[new_ga] = ", ".join(current_ias)
|
||||
new_data_unsorted = existing_data | new_data
|
||||
new_data = {
|
||||
key: new_data_unsorted[key]
|
||||
for key in sorted(new_data_unsorted, key=GroupAddress)
|
||||
}
|
||||
|
||||
issue_registry.async_get_or_create(
|
||||
DOMAIN,
|
||||
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
|
||||
data=new_data, # type: ignore[arg-type]
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key=REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
|
||||
translation_placeholders={
|
||||
"addresses": "\n".join(
|
||||
f"`{ga}` from {ias}" for ga, ias in new_data.items()
|
||||
),
|
||||
"interface": str(knx_module.xknx.current_address),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class DataSecureGroupIssueRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow for outdated DataSecure keys."""
|
||||
|
||||
@callback
|
||||
def _async_get_placeholders(self) -> dict[str, str]:
|
||||
issue_registry = ir.async_get(self.hass)
|
||||
issue = issue_registry.async_get_issue(self.handler, self.issue_id)
|
||||
assert issue is not None
|
||||
return issue.translation_placeholders or {}
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_secure_knxkeys()
|
||||
|
||||
async def async_step_secure_knxkeys(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Manage upload of new KNX Keyring file."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
password = user_input[CONF_KNX_KNXKEY_PASSWORD]
|
||||
keyring = None
|
||||
try:
|
||||
keyring = await save_uploaded_knxkeys_file(
|
||||
self.hass,
|
||||
uploaded_file_id=user_input[CONF_KEYRING_FILE],
|
||||
password=password,
|
||||
)
|
||||
except InvalidSecureConfiguration:
|
||||
errors[CONF_KNX_KNXKEY_PASSWORD] = "keyfile_invalid_signature"
|
||||
|
||||
if not errors and keyring:
|
||||
new_entry_data = KNXConfigEntryData(
|
||||
knxkeys_filename=f"{DOMAIN}/{DEFAULT_KNX_KEYRING_FILENAME}",
|
||||
knxkeys_password=password,
|
||||
)
|
||||
return self.finish_flow(new_entry_data)
|
||||
|
||||
fields = {
|
||||
vol.Required(CONF_KEYRING_FILE): selector.FileSelector(
|
||||
config=selector.FileSelectorConfig(accept=".knxkeys")
|
||||
),
|
||||
vol.Required(CONF_KNX_KNXKEY_PASSWORD): selector.TextSelector(),
|
||||
}
|
||||
return self.async_show_form(
|
||||
step_id="secure_knxkeys",
|
||||
data_schema=vol.Schema(fields),
|
||||
description_placeholders=self._async_get_placeholders(),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@callback
|
||||
def finish_flow(
|
||||
self, new_entry_data: KNXConfigEntryData
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Finish the repair flow. Reload the config entry."""
|
||||
knx_config_entries = self.hass.config_entries.async_entries(DOMAIN)
|
||||
if knx_config_entries:
|
||||
config_entry = knx_config_entries[0] # single_config_entry
|
||||
new_data = {**config_entry.data, **new_entry_data}
|
||||
self.hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||
self.hass.config_entries.async_schedule_reload(config_entry.entry_id)
|
||||
return self.async_create_entry(data={})
|
||||
@@ -39,10 +39,6 @@ if TYPE_CHECKING:
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DESCRIPTION_PLACEHOLDERS = {
|
||||
"sensor_value_types_url": "https://www.home-assistant.io/integrations/knx/#value-types"
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
@@ -52,7 +48,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_KNX_SEND,
|
||||
service_send_to_knx_bus,
|
||||
schema=SERVICE_KNX_SEND_SCHEMA,
|
||||
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
@@ -68,7 +63,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_KNX_EVENT_REGISTER,
|
||||
service_event_register_modify,
|
||||
schema=SERVICE_KNX_EVENT_REGISTER_SCHEMA,
|
||||
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
async_register_admin_service(
|
||||
@@ -77,7 +71,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_KNX_EXPOSURE_REGISTER,
|
||||
service_exposure_register_modify,
|
||||
schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA,
|
||||
description_placeholders=_DESCRIPTION_PLACEHOLDERS,
|
||||
)
|
||||
|
||||
async_register_admin_service(
|
||||
|
||||
@@ -13,9 +13,6 @@ CONF_DPT: Final = "dpt"
|
||||
|
||||
CONF_GA_SENSOR: Final = "ga_sensor"
|
||||
CONF_GA_SWITCH: Final = "ga_switch"
|
||||
CONF_GA_DATE: Final = "ga_date"
|
||||
CONF_GA_DATETIME: Final = "ga_datetime"
|
||||
CONF_GA_TIME: Final = "ga_time"
|
||||
|
||||
# Climate
|
||||
CONF_GA_TEMPERATURE_CURRENT: Final = "ga_temperature_current"
|
||||
|
||||
@@ -46,8 +46,6 @@ from .const import (
|
||||
CONF_GA_COLOR_TEMP,
|
||||
CONF_GA_CONTROLLER_MODE,
|
||||
CONF_GA_CONTROLLER_STATUS,
|
||||
CONF_GA_DATE,
|
||||
CONF_GA_DATETIME,
|
||||
CONF_GA_FAN_SPEED,
|
||||
CONF_GA_FAN_SWING,
|
||||
CONF_GA_FAN_SWING_HORIZONTAL,
|
||||
@@ -74,7 +72,6 @@ from .const import (
|
||||
CONF_GA_SWITCH,
|
||||
CONF_GA_TEMPERATURE_CURRENT,
|
||||
CONF_GA_TEMPERATURE_TARGET,
|
||||
CONF_GA_TIME,
|
||||
CONF_GA_UP_DOWN,
|
||||
CONF_GA_VALVE,
|
||||
CONF_GA_WHITE_BRIGHTNESS,
|
||||
@@ -202,24 +199,6 @@ COVER_KNX_SCHEMA = AllSerializeFirst(
|
||||
),
|
||||
)
|
||||
|
||||
DATE_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GA_DATE): GASelector(write_required=True, valid_dpt="11.001"),
|
||||
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
DATETIME_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GA_DATETIME): GASelector(
|
||||
write_required=True, valid_dpt="19.001"
|
||||
),
|
||||
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@unique
|
||||
class LightColorMode(StrEnum):
|
||||
@@ -357,14 +336,6 @@ SWITCH_KNX_SCHEMA = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
TIME_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_GA_TIME): GASelector(write_required=True, valid_dpt="10.001"),
|
||||
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@unique
|
||||
class ConfSetpointShiftMode(StrEnum):
|
||||
@@ -511,11 +482,8 @@ KNX_SCHEMA_FOR_PLATFORM = {
|
||||
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
|
||||
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
|
||||
Platform.COVER: COVER_KNX_SCHEMA,
|
||||
Platform.DATE: DATE_KNX_SCHEMA,
|
||||
Platform.DATETIME: DATETIME_KNX_SCHEMA,
|
||||
Platform.LIGHT: LIGHT_KNX_SCHEMA,
|
||||
Platform.SWITCH: SWITCH_KNX_SCHEMA,
|
||||
Platform.TIME: TIME_KNX_SCHEMA,
|
||||
}
|
||||
|
||||
ENTITY_STORE_DATA_SCHEMA: VolSchemaType = vol.All(
|
||||
|
||||
@@ -10,10 +10,9 @@ from xknx.secure.keyring import Keyring, sync_load_keyring
|
||||
|
||||
from homeassistant.components.file_upload import process_uploaded_file
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.storage import STORAGE_DIR
|
||||
|
||||
from ..const import DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY
|
||||
from ..const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,11 +45,4 @@ async def save_uploaded_knxkeys_file(
|
||||
shutil.move(file_path, dest_file)
|
||||
return keyring
|
||||
|
||||
keyring = await hass.async_add_executor_job(_process_upload)
|
||||
|
||||
# If there is an existing DataSecure group key issue, remove it.
|
||||
# GAs might not be DataSecure anymore after uploading a valid keyring,
|
||||
# if they are, we raise the issue again when receiving a telegram.
|
||||
ir.async_delete_issue(hass, DOMAIN, REPAIR_ISSUE_DATA_SECURE_GROUP_KEY)
|
||||
|
||||
return keyring
|
||||
return await hass.async_add_executor_job(_process_upload)
|
||||
|
||||
@@ -176,10 +176,6 @@
|
||||
"state_address": "State address",
|
||||
"valid_dpts": "Valid DPTs"
|
||||
},
|
||||
"respond_to_read": {
|
||||
"description": "Respond to GroupValueRead telegrams received to the configured send address.",
|
||||
"label": "Respond to read"
|
||||
},
|
||||
"sync_state": {
|
||||
"description": "Actively request state updates from KNX bus for state addresses.",
|
||||
"options": {
|
||||
@@ -442,24 +438,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"description": "The KNX date platform is used as an interface to date objects.",
|
||||
"knx": {
|
||||
"ga_date": {
|
||||
"description": "The group address of the date object.",
|
||||
"label": "Date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"datetime": {
|
||||
"description": "The KNX datetime platform is used as an interface to date and time objects.",
|
||||
"knx": {
|
||||
"ga_datetime": {
|
||||
"description": "The group address of the date and time object.",
|
||||
"label": "Date and time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"header": "Create new entity",
|
||||
"light": {
|
||||
"description": "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.",
|
||||
@@ -568,15 +546,10 @@
|
||||
"invert": {
|
||||
"description": "Invert payloads before processing or sending.",
|
||||
"label": "Invert"
|
||||
}
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"description": "The KNX time platform is used as an interface to time objects.",
|
||||
"knx": {
|
||||
"ga_time": {
|
||||
"description": "The group address of the time object.",
|
||||
"label": "Time"
|
||||
},
|
||||
"respond_to_read": {
|
||||
"description": "Respond to GroupValueRead telegrams received to the configured send address.",
|
||||
"label": "Respond to read"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -671,30 +644,6 @@
|
||||
"message": "Invalid type for `knx.send` service: {type}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"data_secure_group_key_issue": {
|
||||
"fix_flow": {
|
||||
"error": {
|
||||
"keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]"
|
||||
},
|
||||
"step": {
|
||||
"secure_knxkeys": {
|
||||
"data": {
|
||||
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]",
|
||||
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]",
|
||||
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]"
|
||||
},
|
||||
"description": "Telegrams for group addresses used in Home Assistant could not be decrypted because Data Secure keys are missing or invalid:\n\n{addresses}\n\nTo fix this, update the sending devices configurations via ETS and provide an updated KNX Keyring file. Make sure that the group addresses used in Home Assistant are associated with the interface used by Home Assistant (`{interface}` when the issue last occurred).",
|
||||
"title": "Update KNX Keyring"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "KNX Data Secure telegrams can't be decrypted"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"communication_settings": {
|
||||
@@ -725,7 +674,7 @@
|
||||
"name": "Remove event registration"
|
||||
},
|
||||
"type": {
|
||||
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see {sensor_value_types_url}).",
|
||||
"description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
|
||||
"name": "Value type"
|
||||
}
|
||||
},
|
||||
@@ -755,7 +704,7 @@
|
||||
"name": "Remove exposure"
|
||||
},
|
||||
"type": {
|
||||
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see {sensor_value_types_url}).",
|
||||
"description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
|
||||
"name": "Value type"
|
||||
}
|
||||
},
|
||||
@@ -791,7 +740,7 @@
|
||||
"name": "Send as Response"
|
||||
},
|
||||
"type": {
|
||||
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see {sensor_value_types_url}).",
|
||||
"description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types).",
|
||||
"name": "Value type"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,9 +26,6 @@ STORAGE_KEY: Final = f"{DOMAIN}/telegrams_history.json"
|
||||
|
||||
# dispatcher signal for KNX interface device triggers
|
||||
SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram")
|
||||
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType(
|
||||
"knx_data_secure_issue_telegram"
|
||||
)
|
||||
|
||||
|
||||
class DecodedTelegramPayload(TypedDict):
|
||||
@@ -77,11 +74,6 @@ class Telegrams:
|
||||
match_for_outgoing=True,
|
||||
)
|
||||
)
|
||||
self._xknx_data_secure_group_key_issue_cb_handle = (
|
||||
xknx.telegram_queue.register_data_secure_group_key_issue_cb(
|
||||
self._xknx_data_secure_group_key_issue_cb,
|
||||
)
|
||||
)
|
||||
self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size)
|
||||
self.last_ga_telegrams: dict[str, TelegramDict] = {}
|
||||
|
||||
@@ -115,14 +107,6 @@ class Telegrams:
|
||||
self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict
|
||||
async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict)
|
||||
|
||||
def _xknx_data_secure_group_key_issue_cb(self, telegram: Telegram) -> None:
|
||||
"""Handle telegrams with undecodable data secure payload from xknx."""
|
||||
telegram_dict = self.telegram_to_dict(telegram)
|
||||
self.recent_telegrams.append(telegram_dict)
|
||||
async_dispatcher_send(
|
||||
self.hass, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, telegram, telegram_dict
|
||||
)
|
||||
|
||||
def telegram_to_dict(self, telegram: Telegram) -> TelegramDict:
|
||||
"""Convert a Telegram to a dict."""
|
||||
dst_name = ""
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import time as dt_time
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import TimeDevice as XknxTimeDevice
|
||||
from xknx.dpt.dpt_10 import KNXTime as XknxTime
|
||||
|
||||
@@ -18,10 +18,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -29,14 +26,11 @@ from .const import (
|
||||
CONF_RESPOND_TO_READ,
|
||||
CONF_STATE_ADDRESS,
|
||||
CONF_SYNC_STATE,
|
||||
DOMAIN,
|
||||
KNX_ADDRESS,
|
||||
KNX_MODULE_KEY,
|
||||
)
|
||||
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
|
||||
from .entity import KnxYamlEntity
|
||||
from .knx_module import KNXModule
|
||||
from .storage.const import CONF_ENTITY, CONF_GA_TIME
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -46,36 +40,40 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up entities for KNX platform."""
|
||||
knx_module = hass.data[KNX_MODULE_KEY]
|
||||
platform = async_get_current_platform()
|
||||
knx_module.config_store.add_platform(
|
||||
platform=Platform.TIME,
|
||||
controller=KnxUiEntityPlatformController(
|
||||
knx_module=knx_module,
|
||||
entity_platform=platform,
|
||||
entity_class=KnxUiTime,
|
||||
),
|
||||
config: list[ConfigType] = knx_module.config_yaml[Platform.TIME]
|
||||
|
||||
async_add_entities(
|
||||
KNXTimeEntity(knx_module, entity_config) for entity_config in config
|
||||
)
|
||||
|
||||
entities: list[KnxYamlEntity | KnxUiEntity] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.TIME):
|
||||
entities.extend(
|
||||
KnxYamlTime(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
)
|
||||
if ui_config := knx_module.config_store.data["entities"].get(Platform.TIME):
|
||||
entities.extend(
|
||||
KnxUiTime(knx_module, unique_id, config)
|
||||
for unique_id, config in ui_config.items()
|
||||
)
|
||||
if entities:
|
||||
async_add_entities(entities)
|
||||
|
||||
def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxTimeDevice:
|
||||
"""Return a XKNX DateTime object to be used within XKNX."""
|
||||
return XknxTimeDevice(
|
||||
xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
)
|
||||
|
||||
|
||||
class _KNXTime(TimeEntity, RestoreEntity):
|
||||
class KNXTimeEntity(KnxYamlEntity, TimeEntity, RestoreEntity):
|
||||
"""Representation of a KNX time."""
|
||||
|
||||
_device: XknxTimeDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=_create_xknx_device(knx_module.xknx, config),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore last state."""
|
||||
await super().async_added_to_hass()
|
||||
@@ -96,52 +94,3 @@ class _KNXTime(TimeEntity, RestoreEntity):
|
||||
async def async_set_value(self, value: dt_time) -> None:
|
||||
"""Change the value."""
|
||||
await self._device.set(value)
|
||||
|
||||
|
||||
class KnxYamlTime(_KNXTime, KnxYamlEntity):
|
||||
"""Representation of a KNX time configured from YAML."""
|
||||
|
||||
_device: XknxTimeDevice
|
||||
|
||||
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
|
||||
"""Initialize a KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
device=XknxTimeDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=config[KNX_ADDRESS],
|
||||
group_address_state=config.get(CONF_STATE_ADDRESS),
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
sync_state=config[CONF_SYNC_STATE],
|
||||
),
|
||||
)
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
self._attr_unique_id = str(self._device.remote_value.group_address)
|
||||
|
||||
|
||||
class KnxUiTime(_KNXTime, KnxUiEntity):
|
||||
"""Representation of a KNX time configured from the UI."""
|
||||
|
||||
_device: XknxTimeDevice
|
||||
|
||||
def __init__(
|
||||
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Initialize KNX time."""
|
||||
super().__init__(
|
||||
knx_module=knx_module,
|
||||
unique_id=unique_id,
|
||||
entity_config=config[CONF_ENTITY],
|
||||
)
|
||||
knx_conf = ConfigExtractor(config[DOMAIN])
|
||||
self._device = XknxTimeDevice(
|
||||
knx_module.xknx,
|
||||
name=config[CONF_ENTITY][CONF_NAME],
|
||||
localtime=False,
|
||||
group_address=knx_conf.get_write(CONF_GA_TIME),
|
||||
group_address_state=knx_conf.get_state_and_passive(CONF_GA_TIME),
|
||||
respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
|
||||
sync_state=knx_conf.get(CONF_SYNC_STATE),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import ExitStack
|
||||
from functools import wraps
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, Any, Final, overload
|
||||
@@ -35,11 +34,7 @@ from .storage.entity_store_validation import (
|
||||
validate_entity_data,
|
||||
)
|
||||
from .storage.serialize import get_serialized_schema
|
||||
from .telegrams import (
|
||||
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
|
||||
SIGNAL_KNX_TELEGRAM,
|
||||
TelegramDict,
|
||||
)
|
||||
from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .knx_module import KNXModule
|
||||
@@ -339,23 +334,11 @@ def ws_subscribe_telegram(
|
||||
telegram_dict,
|
||||
)
|
||||
|
||||
stack = ExitStack()
|
||||
stack.callback(
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
signal=SIGNAL_KNX_TELEGRAM,
|
||||
target=forward_telegram,
|
||||
)
|
||||
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
|
||||
hass,
|
||||
signal=SIGNAL_KNX_TELEGRAM,
|
||||
target=forward_telegram,
|
||||
)
|
||||
stack.callback(
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
signal=SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
|
||||
target=forward_telegram,
|
||||
)
|
||||
)
|
||||
|
||||
connection.subscriptions[msg["id"]] = stack.close
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .coordinator import (
|
||||
LaMarzoccoBluetoothUpdateCoordinator,
|
||||
LaMarzoccoConfigEntry,
|
||||
LaMarzoccoConfigUpdateCoordinator,
|
||||
LaMarzoccoRuntimeData,
|
||||
@@ -73,10 +72,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
client=create_client_session(hass),
|
||||
)
|
||||
|
||||
try:
|
||||
settings = await cloud_client.get_thing_settings(serial)
|
||||
except AuthFail as ex:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||
) from ex
|
||||
except (RequestNotSuccessful, TimeoutError) as ex:
|
||||
_LOGGER.debug(ex, exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="api_error"
|
||||
) from ex
|
||||
|
||||
gateway_version = version.parse(
|
||||
settings.firmwares[FirmwareType.GATEWAY].build_version
|
||||
)
|
||||
|
||||
if gateway_version < version.parse("v5.0.9"):
|
||||
# incompatible gateway firmware, create an issue
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"unsupported_gateway_firmware",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="unsupported_gateway_firmware",
|
||||
translation_placeholders={"gateway_version": str(gateway_version)},
|
||||
)
|
||||
|
||||
# initialize Bluetooth
|
||||
bluetooth_client: LaMarzoccoBluetoothClient | None = None
|
||||
if entry.options.get(CONF_USE_BLUETOOTH, True) and (
|
||||
token := entry.data.get(CONF_TOKEN)
|
||||
token := (entry.data.get(CONF_TOKEN) or settings.ble_auth_token)
|
||||
):
|
||||
if CONF_MAC not in entry.data:
|
||||
for discovery_info in async_discovered_service_info(hass):
|
||||
@@ -118,44 +145,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
_LOGGER.info(
|
||||
"Bluetooth device not found during lamarzocco setup, continuing with cloud only"
|
||||
)
|
||||
try:
|
||||
settings = await cloud_client.get_thing_settings(serial)
|
||||
except AuthFail as ex:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||
) from ex
|
||||
except (RequestNotSuccessful, TimeoutError) as ex:
|
||||
_LOGGER.debug(ex, exc_info=True)
|
||||
if not bluetooth_client:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="api_error"
|
||||
) from ex
|
||||
_LOGGER.debug("Cloud failed, continuing with Bluetooth only", exc_info=True)
|
||||
else:
|
||||
gateway_version = version.parse(
|
||||
settings.firmwares[FirmwareType.GATEWAY].build_version
|
||||
)
|
||||
|
||||
if gateway_version < version.parse("v5.0.9"):
|
||||
# incompatible gateway firmware, create an issue
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"unsupported_gateway_firmware",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="unsupported_gateway_firmware",
|
||||
translation_placeholders={"gateway_version": str(gateway_version)},
|
||||
)
|
||||
# Update BLE Token if exists
|
||||
if settings.ble_auth_token:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_TOKEN: settings.ble_auth_token,
|
||||
},
|
||||
)
|
||||
|
||||
device = LaMarzoccoMachine(
|
||||
serial_number=entry.unique_id,
|
||||
@@ -164,7 +153,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
)
|
||||
|
||||
coordinators = LaMarzoccoRuntimeData(
|
||||
LaMarzoccoConfigUpdateCoordinator(hass, entry, device),
|
||||
LaMarzoccoConfigUpdateCoordinator(hass, entry, device, cloud_client),
|
||||
LaMarzoccoSettingsUpdateCoordinator(hass, entry, device),
|
||||
LaMarzoccoScheduleUpdateCoordinator(hass, entry, device),
|
||||
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
|
||||
@@ -177,16 +166,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
coordinators.statistics_coordinator.async_config_entry_first_refresh(),
|
||||
)
|
||||
|
||||
# bt coordinator only if bluetooth client is available
|
||||
# and after the initial refresh of the config coordinator
|
||||
# to fetch only if the others failed
|
||||
if bluetooth_client:
|
||||
bluetooth_coordinator = LaMarzoccoBluetoothUpdateCoordinator(
|
||||
hass, entry, device
|
||||
)
|
||||
await bluetooth_coordinator.async_config_entry_first_refresh()
|
||||
coordinators.bluetooth_coordinator = bluetooth_coordinator
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import cast
|
||||
|
||||
from pylamarzocco import LaMarzoccoMachine
|
||||
from pylamarzocco.const import BackFlushStatus, MachineState, ModelName, WidgetType
|
||||
from pylamarzocco.models import BackFlush, MachineStatus, NoWater
|
||||
from pylamarzocco.models import BackFlush, MachineStatus
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -39,15 +39,8 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
|
||||
key="water_tank",
|
||||
translation_key="water_tank",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
is_on_fn=(
|
||||
lambda machine: cast(
|
||||
NoWater, machine.dashboard.config[WidgetType.CM_NO_WATER]
|
||||
).allarm
|
||||
if WidgetType.CM_NO_WATER in machine.dashboard.config
|
||||
else False
|
||||
),
|
||||
is_on_fn=lambda machine: WidgetType.CM_NO_WATER in machine.dashboard.config,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoBinarySensorEntityDescription(
|
||||
key="brew_active",
|
||||
@@ -100,9 +93,7 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data.config_coordinator
|
||||
|
||||
async_add_entities(
|
||||
LaMarzoccoBinarySensorEntity(
|
||||
coordinator, description, entry.runtime_data.bluetooth_coordinator
|
||||
)
|
||||
LaMarzoccoBinarySensorEntity(coordinator, description)
|
||||
for description in ENTITIES
|
||||
if description.supported_fn(coordinator)
|
||||
)
|
||||
|
||||
@@ -10,12 +10,8 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pylamarzocco import LaMarzoccoMachine
|
||||
from pylamarzocco.exceptions import (
|
||||
AuthFail,
|
||||
BluetoothConnectionFailed,
|
||||
RequestNotSuccessful,
|
||||
)
|
||||
from pylamarzocco import LaMarzoccoCloudClient, LaMarzoccoMachine
|
||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
@@ -40,7 +36,6 @@ class LaMarzoccoRuntimeData:
|
||||
settings_coordinator: LaMarzoccoSettingsUpdateCoordinator
|
||||
schedule_coordinator: LaMarzoccoScheduleUpdateCoordinator
|
||||
statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator
|
||||
bluetooth_coordinator: LaMarzoccoBluetoothUpdateCoordinator | None = None
|
||||
|
||||
|
||||
type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData]
|
||||
@@ -51,13 +46,14 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
_default_update_interval = SCAN_INTERVAL
|
||||
config_entry: LaMarzoccoConfigEntry
|
||||
update_success = False
|
||||
_websocket_task: Task | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: LaMarzoccoConfigEntry,
|
||||
device: LaMarzoccoMachine,
|
||||
cloud_client: LaMarzoccoCloudClient | None = None,
|
||||
) -> None:
|
||||
"""Initialize coordinator."""
|
||||
super().__init__(
|
||||
@@ -68,7 +64,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
update_interval=self._default_update_interval,
|
||||
)
|
||||
self.device = device
|
||||
self._websocket_task: Task | None = None
|
||||
self.cloud_client = cloud_client
|
||||
|
||||
@property
|
||||
def websocket_terminated(self) -> bool:
|
||||
@@ -85,28 +81,14 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
await func()
|
||||
except AuthFail as ex:
|
||||
_LOGGER.debug("Authentication failed", exc_info=True)
|
||||
self.update_success = False
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||
) from ex
|
||||
except RequestNotSuccessful as ex:
|
||||
_LOGGER.debug(ex, exc_info=True)
|
||||
self.update_success = False
|
||||
# if no bluetooth coordinator, this is a fatal error
|
||||
# otherwise, bluetooth may still work
|
||||
if not self.device.bluetooth_client_available:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN, translation_key="api_error"
|
||||
) from ex
|
||||
except BluetoothConnectionFailed as err:
|
||||
self.update_success = False
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="bluetooth_connection_failed",
|
||||
) from err
|
||||
else:
|
||||
self.update_success = True
|
||||
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
||||
translation_domain=DOMAIN, translation_key="api_error"
|
||||
) from ex
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up coordinator."""
|
||||
@@ -127,9 +109,11 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Class to handle fetching data from the La Marzocco API centrally."""
|
||||
|
||||
cloud_client: LaMarzoccoCloudClient
|
||||
|
||||
async def _internal_async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
await self.device.ensure_token_valid()
|
||||
await self.cloud_client.async_get_access_token()
|
||||
await self.device.get_dashboard()
|
||||
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
||||
|
||||
@@ -137,7 +121,7 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Fetch data from API endpoint."""
|
||||
|
||||
# ensure token stays valid; does nothing if token is still valid
|
||||
await self.device.ensure_token_valid()
|
||||
await self.cloud_client.async_get_access_token()
|
||||
|
||||
# Only skip websocket reconnection if it's currently connected and the task is still running
|
||||
if self.device.websocket.connected and not self.websocket_terminated:
|
||||
@@ -209,19 +193,3 @@ class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Fetch data from API endpoint."""
|
||||
await self.device.get_coffee_and_flush_counter()
|
||||
_LOGGER.debug("Current statistics: %s", self.device.statistics.to_dict())
|
||||
|
||||
|
||||
class LaMarzoccoBluetoothUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Class to handle fetching data from the La Marzocco Bluetooth API centrally."""
|
||||
|
||||
async def _internal_async_setup(self) -> None:
|
||||
"""Initial setup for Bluetooth coordinator."""
|
||||
await self.device.get_model_info_from_bluetooth()
|
||||
|
||||
async def _internal_async_update_data(self) -> None:
|
||||
"""Fetch data from Bluetooth endpoint."""
|
||||
# if the websocket is connected and the machine is connected to the cloud
|
||||
# skip bluetooth update, because we get push updates
|
||||
if self.device.websocket.connected and self.device.dashboard.connected:
|
||||
return
|
||||
await self.device.get_dashboard_from_bluetooth()
|
||||
|
||||
@@ -17,10 +17,7 @@ from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
LaMarzoccoBluetoothUpdateCoordinator,
|
||||
LaMarzoccoUpdateCoordinator,
|
||||
)
|
||||
from .coordinator import LaMarzoccoUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -29,7 +26,6 @@ class LaMarzoccoEntityDescription(EntityDescription):
|
||||
|
||||
available_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True
|
||||
supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True
|
||||
bt_offline_mode: bool = False
|
||||
|
||||
|
||||
class LaMarzoccoBaseEntity(
|
||||
@@ -49,19 +45,14 @@ class LaMarzoccoBaseEntity(
|
||||
super().__init__(coordinator)
|
||||
device = coordinator.device
|
||||
self._attr_unique_id = f"{device.serial_number}_{key}"
|
||||
sw_version = (
|
||||
device.settings.firmwares[FirmwareType.MACHINE].build_version
|
||||
if FirmwareType.MACHINE in device.settings.firmwares
|
||||
else None
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.serial_number)},
|
||||
name=device.dashboard.name or self.coordinator.config_entry.title,
|
||||
name=device.dashboard.name,
|
||||
manufacturer="La Marzocco",
|
||||
model=device.dashboard.model_name.value,
|
||||
model_id=device.dashboard.model_code.value,
|
||||
serial_number=device.serial_number,
|
||||
sw_version=sw_version,
|
||||
sw_version=device.settings.firmwares[FirmwareType.MACHINE].build_version,
|
||||
)
|
||||
connections: set[tuple[str, str]] = set()
|
||||
if coordinator.config_entry.data.get(CONF_ADDRESS):
|
||||
@@ -86,12 +77,8 @@ class LaMarzoccoBaseEntity(
|
||||
if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config
|
||||
else MachineState.OFF
|
||||
)
|
||||
return (
|
||||
super().available
|
||||
and not (
|
||||
self._unavailable_when_machine_off and machine_state is MachineState.OFF
|
||||
)
|
||||
and self.coordinator.update_success
|
||||
return super().available and not (
|
||||
self._unavailable_when_machine_off and machine_state is MachineState.OFF
|
||||
)
|
||||
|
||||
|
||||
@@ -103,11 +90,6 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
if (
|
||||
self.entity_description.bt_offline_mode
|
||||
and self.bluetooth_coordinator is not None
|
||||
):
|
||||
return self.bluetooth_coordinator.last_update_success
|
||||
if super().available:
|
||||
return self.entity_description.available_fn(self.coordinator)
|
||||
return False
|
||||
@@ -116,17 +98,7 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
|
||||
self,
|
||||
coordinator: LaMarzoccoUpdateCoordinator,
|
||||
entity_description: LaMarzoccoEntityDescription,
|
||||
bluetooth_coordinator: LaMarzoccoBluetoothUpdateCoordinator | None = None,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, entity_description.key)
|
||||
self.entity_description = entity_description
|
||||
self.bluetooth_coordinator = bluetooth_coordinator
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle when entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if self.bluetooth_coordinator is not None:
|
||||
self.async_on_remove(
|
||||
self.bluetooth_coordinator.async_add_listener(self.async_write_ha_state)
|
||||
)
|
||||
|
||||
@@ -58,7 +58,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
|
||||
).target_temperature
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoNumberEntityDescription(
|
||||
key="steam_temp",
|
||||
@@ -79,7 +78,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
lambda coordinator: coordinator.device.dashboard.model_name
|
||||
in (ModelName.GS3_AV, ModelName.GS3_MP)
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoNumberEntityDescription(
|
||||
key="smart_standby_time",
|
||||
@@ -98,7 +96,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
)
|
||||
),
|
||||
native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoNumberEntityDescription(
|
||||
key="preinfusion_off",
|
||||
@@ -229,14 +226,13 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up number entities."""
|
||||
coordinator = entry.runtime_data.config_coordinator
|
||||
|
||||
async_add_entities(
|
||||
LaMarzoccoNumberEntity(
|
||||
coordinator, description, entry.runtime_data.bluetooth_coordinator
|
||||
)
|
||||
entities: list[NumberEntity] = [
|
||||
LaMarzoccoNumberEntity(coordinator, description)
|
||||
for description in ENTITIES
|
||||
if description.supported_fn(coordinator)
|
||||
)
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
|
||||
|
||||
@@ -80,7 +80,6 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
|
||||
lambda coordinator: coordinator.device.dashboard.model_name
|
||||
in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoSelectEntityDescription(
|
||||
key="prebrew_infusion_select",
|
||||
@@ -129,9 +128,7 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data.config_coordinator
|
||||
|
||||
async_add_entities(
|
||||
LaMarzoccoSelectEntity(
|
||||
coordinator, description, entry.runtime_data.bluetooth_coordinator
|
||||
)
|
||||
LaMarzoccoSelectEntity(coordinator, description)
|
||||
for description in ENTITIES
|
||||
if description.supported_fn(coordinator)
|
||||
)
|
||||
|
||||
@@ -183,9 +183,6 @@
|
||||
"auto_on_off_error": {
|
||||
"message": "Error while setting auto on/off to {state} for {id}"
|
||||
},
|
||||
"bluetooth_connection_failed": {
|
||||
"message": "Error while connecting to machine via Bluetooth"
|
||||
},
|
||||
"button_error": {
|
||||
"message": "Error while executing button {key}"
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
||||
).mode
|
||||
is MachineMode.BREWING_MODE
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoSwitchEntityDescription(
|
||||
key="steam_boiler_enable",
|
||||
@@ -66,7 +65,6 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
||||
lambda coordinator: coordinator.device.dashboard.model_name
|
||||
in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoSwitchEntityDescription(
|
||||
key="steam_boiler_enable",
|
||||
@@ -82,7 +80,6 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
||||
lambda coordinator: coordinator.device.dashboard.model_name
|
||||
not in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
|
||||
),
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
LaMarzoccoSwitchEntityDescription(
|
||||
key="smart_standby_enabled",
|
||||
@@ -94,7 +91,6 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
|
||||
minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
|
||||
),
|
||||
is_on_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
|
||||
bt_offline_mode=True,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -110,9 +106,7 @@ async def async_setup_entry(
|
||||
|
||||
entities: list[SwitchEntity] = []
|
||||
entities.extend(
|
||||
LaMarzoccoSwitchEntity(
|
||||
coordinator, description, entry.runtime_data.bluetooth_coordinator
|
||||
)
|
||||
LaMarzoccoSwitchEntity(coordinator, description)
|
||||
for description in ENTITIES
|
||||
if description.supported_fn(coordinator)
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Virtual integration: Levoit."""
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"domain": "levoit",
|
||||
"name": "Levoit",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "vesync"
|
||||
}
|
||||
@@ -9,7 +9,6 @@ post:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
visibility:
|
||||
selector:
|
||||
select:
|
||||
|
||||
@@ -378,33 +378,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate the options from config entry data."""
|
||||
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
data: dict[str, Any] = dict(entry.data)
|
||||
options: dict[str, Any] = dict(entry.options)
|
||||
if entry.version > 2 or (entry.version == 2 and entry.minor_version > 1):
|
||||
if entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
# We allow read support for version 2.1
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
# Can be removed when the config entry is bumped to version 2.1
|
||||
# with HA Core 2026.7.0. Read support for version 2.1 is expected with 2026.1
|
||||
# From 2026.7 we will write version 2.1
|
||||
# Can be removed when config entry is bumped to version 2.1
|
||||
# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1
|
||||
# From 2026.1 we will write version 2.1
|
||||
for key in ENTRY_OPTION_FIELDS:
|
||||
if key not in data:
|
||||
continue
|
||||
options[key] = data.pop(key)
|
||||
# Write version 1.2 for backwards compatibility
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=data,
|
||||
options=options,
|
||||
version=1,
|
||||
minor_version=2,
|
||||
version=CONFIG_ENTRY_VERSION,
|
||||
minor_version=CONFIG_ENTRY_MINOR_VERSION,
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s.%s successful", entry.version, entry.minor_version
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -3952,8 +3952,9 @@ REAUTH_SCHEMA = vol.Schema(
|
||||
class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
VERSION = CONFIG_ENTRY_VERSION # 2
|
||||
MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 1
|
||||
# Can be bumped to version 2.1 with HA Core 2026.1.0
|
||||
VERSION = CONFIG_ENTRY_VERSION # 1
|
||||
MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 2
|
||||
|
||||
_hassio_discovery: dict[str, Any] | None = None
|
||||
_addon_manager: AddonManager
|
||||
|
||||
@@ -381,13 +381,13 @@ MQTT_PROCESSED_SUBSCRIPTIONS = "mqtt_processed_subscriptions"
|
||||
PAYLOAD_EMPTY_JSON = "{}"
|
||||
PAYLOAD_NONE = "None"
|
||||
|
||||
CONFIG_ENTRY_VERSION = 2
|
||||
CONFIG_ENTRY_MINOR_VERSION = 1
|
||||
CONFIG_ENTRY_VERSION = 1
|
||||
CONFIG_ENTRY_MINOR_VERSION = 2
|
||||
|
||||
# Split mqtt entry data and options
|
||||
# Can be removed when config entry is bumped to version 2.1
|
||||
# with HA Core 2026.7.0. Read support for version 2.1 is expected from 2026.1
|
||||
# From 2026.7 we will write version 2.1
|
||||
# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1
|
||||
# From 2026.1 we will write version 2.1
|
||||
ENTRY_OPTION_FIELDS = (
|
||||
CONF_DISCOVERY,
|
||||
CONF_DISCOVERY_PREFIX,
|
||||
|
||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from pymystrom.switch import MyStromSwitch
|
||||
|
||||
@@ -15,16 +13,10 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
EntityCategory,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.const import UnitOfPower, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .models import MyStromConfigEntry
|
||||
@@ -52,15 +44,6 @@ SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
value_fn=lambda device: device.consumption,
|
||||
),
|
||||
MyStromSwitchSensorEntityDescription(
|
||||
key="energy_since_boot",
|
||||
translation_key="energy_since_boot",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.JOULE,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_fn=lambda device: device.energy_since_boot,
|
||||
),
|
||||
MyStromSwitchSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
@@ -79,44 +62,20 @@ async def async_setup_entry(
|
||||
"""Set up the myStrom entities."""
|
||||
device: MyStromSwitch = entry.runtime_data.device
|
||||
|
||||
entities: list[MyStromSensorBase] = [
|
||||
async_add_entities(
|
||||
MyStromSwitchSensor(device, entry.title, description)
|
||||
for description in SENSOR_TYPES
|
||||
if description.value_fn(device) is not None
|
||||
]
|
||||
|
||||
if device.time_since_boot is not None:
|
||||
entities.append(MyStromSwitchUptimeSensor(device, entry.title))
|
||||
|
||||
async_add_entities(entities)
|
||||
)
|
||||
|
||||
|
||||
class MyStromSensorBase(SensorEntity):
|
||||
"""Base class for myStrom sensors."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: MyStromSwitch,
|
||||
name: str,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
self._attr_unique_id = f"{device.mac}-{key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.mac)},
|
||||
name=name,
|
||||
manufacturer=MANUFACTURER,
|
||||
sw_version=device.firmware,
|
||||
)
|
||||
|
||||
|
||||
class MyStromSwitchSensor(MyStromSensorBase):
|
||||
class MyStromSwitchSensor(SensorEntity):
|
||||
"""Representation of the consumption or temperature of a myStrom switch/plug."""
|
||||
|
||||
entity_description: MyStromSwitchSensorEntityDescription
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: MyStromSwitch,
|
||||
@@ -124,61 +83,18 @@ class MyStromSwitchSensor(MyStromSensorBase):
|
||||
description: MyStromSwitchSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(device, name, description.key)
|
||||
self.device = device
|
||||
self.entity_description = description
|
||||
|
||||
self._attr_unique_id = f"{device.mac}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.mac)},
|
||||
name=name,
|
||||
manufacturer=MANUFACTURER,
|
||||
sw_version=device.firmware,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the value of the sensor."""
|
||||
return self.entity_description.value_fn(self.device)
|
||||
|
||||
|
||||
class MyStromSwitchUptimeSensor(MyStromSensorBase):
|
||||
"""Representation of a MyStrom Switch uptime sensor."""
|
||||
|
||||
entity_description = SensorEntityDescription(
|
||||
key="time_since_boot",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
translation_key="time_since_boot",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: MyStromSwitch,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Initialize the uptime sensor."""
|
||||
super().__init__(device, name, self.entity_description.key)
|
||||
self.device = device
|
||||
self._last_value: datetime | None = None
|
||||
self._last_attributes: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | None:
|
||||
"""Return the uptime of the device as a datetime."""
|
||||
|
||||
if self.device.time_since_boot is None or self.device.boot_id is None:
|
||||
return None
|
||||
|
||||
# Return cached value if boot_id hasn't changed
|
||||
if (
|
||||
self._last_value is not None
|
||||
and self._last_attributes.get("boot_id") == self.device.boot_id
|
||||
):
|
||||
return self._last_value
|
||||
|
||||
self._last_value = utcnow() - timedelta(seconds=self.device.time_since_boot)
|
||||
|
||||
return self._last_value
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the optional state attributes."""
|
||||
|
||||
self._last_attributes = {
|
||||
"boot_id": self.device.boot_id,
|
||||
}
|
||||
|
||||
return self._last_attributes
|
||||
|
||||
@@ -22,12 +22,6 @@
|
||||
"sensor": {
|
||||
"avg_consumption": {
|
||||
"name": "Average consumption"
|
||||
},
|
||||
"energy_since_boot": {
|
||||
"name": "Energy since boot"
|
||||
},
|
||||
"time_since_boot": {
|
||||
"name": "Last restart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,47 +10,6 @@
|
||||
"is_going": {
|
||||
"default": "mdi:bell-cancel-outline"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"arrival_platform_actual": {
|
||||
"default": "mdi:logout"
|
||||
},
|
||||
"arrival_platform_planned": {
|
||||
"default": "mdi:logout"
|
||||
},
|
||||
"arrival_time_actual": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"arrival_time_planned": {
|
||||
"default": "mdi:calendar-clock"
|
||||
},
|
||||
"departure": {
|
||||
"default": "mdi:train"
|
||||
},
|
||||
"departure_platform_actual": {
|
||||
"default": "mdi:login"
|
||||
},
|
||||
"departure_platform_planned": {
|
||||
"default": "mdi:login"
|
||||
},
|
||||
"departure_time_actual": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"departure_time_planned": {
|
||||
"default": "mdi:calendar-clock"
|
||||
},
|
||||
"next_departure_time": {
|
||||
"default": "mdi:train"
|
||||
},
|
||||
"route": {
|
||||
"default": "mdi:transit-connection-variant"
|
||||
},
|
||||
"status": {
|
||||
"default": "mdi:information"
|
||||
},
|
||||
"transfers": {
|
||||
"default": "mdi:swap-horizontal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -15,10 +13,9 @@ from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME, EntityCategory
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
@@ -27,10 +24,9 @@ from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .binary_sensor import get_delay
|
||||
from .const import (
|
||||
CONF_FROM,
|
||||
CONF_ROUTES,
|
||||
@@ -44,7 +40,7 @@ from .const import (
|
||||
from .coordinator import NSConfigEntry, NSDataUpdateCoordinator
|
||||
|
||||
|
||||
def get_departure_time(trip: Trip | None) -> datetime | None:
|
||||
def _get_departure_time(trip: Trip | None) -> datetime | None:
|
||||
"""Get next departure time from trip data."""
|
||||
return trip.departure_time_actual or trip.departure_time_planned if trip else None
|
||||
|
||||
@@ -65,15 +61,13 @@ def _get_route(trip: Trip | None) -> list[str]:
|
||||
return route
|
||||
|
||||
|
||||
TRIP_STATUS = {
|
||||
"NORMAL": "normal",
|
||||
"CANCELLED": "cancelled",
|
||||
}
|
||||
def _get_delay(planned: datetime | None, actual: datetime | None) -> bool:
|
||||
"""Return True if delay is present, False otherwise."""
|
||||
return bool(planned and actual and planned != actual)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0 # since we use coordinator pattern
|
||||
|
||||
ROUTE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
@@ -91,110 +85,6 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class NSSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Nederlandse Spoorwegen sensor entity."""
|
||||
|
||||
is_next: bool = False
|
||||
value_fn: Callable[[Trip], datetime | str | int | None]
|
||||
entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
|
||||
|
||||
|
||||
# Entity descriptions for all the different sensors we create per route
|
||||
SENSOR_DESCRIPTIONS: tuple[NSSensorEntityDescription, ...] = (
|
||||
NSSensorEntityDescription(
|
||||
key="actual_departure",
|
||||
translation_key="departure",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=None,
|
||||
value_fn=get_departure_time,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="next_departure",
|
||||
translation_key="next_departure_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
is_next=True,
|
||||
value_fn=get_departure_time,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Platform information
|
||||
NSSensorEntityDescription(
|
||||
key="departure_platform_planned",
|
||||
translation_key="departure_platform_planned",
|
||||
value_fn=lambda trip: getattr(trip, "departure_platform_planned", None),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="departure_platform_actual",
|
||||
translation_key="departure_platform_actual",
|
||||
value_fn=lambda trip: trip.departure_platform_actual,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="arrival_platform_planned",
|
||||
translation_key="arrival_platform_planned",
|
||||
value_fn=lambda trip: trip.arrival_platform_planned,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="arrival_platform_actual",
|
||||
translation_key="arrival_platform_actual",
|
||||
value_fn=lambda trip: trip.arrival_platform_actual,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="departure_time_planned",
|
||||
translation_key="departure_time_planned",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda trip: trip.departure_time_planned,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="departure_time_actual",
|
||||
translation_key="departure_time_actual",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda trip: trip.departure_time_actual,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="arrival_time_planned",
|
||||
translation_key="arrival_time_planned",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda trip: trip.arrival_time_planned,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="arrival_time_actual",
|
||||
translation_key="arrival_time_actual",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda trip: trip.arrival_time_actual,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Trip information
|
||||
NSSensorEntityDescription(
|
||||
key="status",
|
||||
translation_key="status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(TRIP_STATUS.values()),
|
||||
value_fn=lambda trip: TRIP_STATUS.get(trip.status),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
NSSensorEntityDescription(
|
||||
key="transfers",
|
||||
translation_key="transfers",
|
||||
value_fn=lambda trip: trip.nr_transfers if trip else 0,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Route info sensors
|
||||
NSSensorEntityDescription(
|
||||
key="route",
|
||||
translation_key="route",
|
||||
value_fn=lambda trip: ", ".join(_get_route(trip)),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
@@ -254,61 +144,58 @@ async def async_setup_entry(
|
||||
coordinators = config_entry.runtime_data
|
||||
|
||||
for subentry_id, coordinator in coordinators.items():
|
||||
async_add_entities(
|
||||
[
|
||||
NSSensor(coordinator, subentry_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
],
|
||||
config_subentry_id=subentry_id,
|
||||
# Build entity from coordinator fields directly
|
||||
entity = NSDepartureSensor(
|
||||
subentry_id,
|
||||
coordinator,
|
||||
)
|
||||
|
||||
# Add entity with proper subentry association
|
||||
async_add_entities([entity], config_subentry_id=subentry_id)
|
||||
|
||||
class NSSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
|
||||
"""Generic NS sensor based on entity description."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
|
||||
"""Implementation of a NS Departure Sensor (legacy)."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_attribution = "Data provided by NS"
|
||||
entity_description: NSSensorEntityDescription
|
||||
_attr_icon = "mdi:train"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NSDataUpdateCoordinator,
|
||||
subentry_id: str,
|
||||
description: NSSensorEntityDescription,
|
||||
coordinator: NSDataUpdateCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_entity_category = description.entity_category
|
||||
self._name = coordinator.name
|
||||
self._subentry_id = subentry_id
|
||||
|
||||
self._attr_unique_id = f"{subentry_id}-{description.key}"
|
||||
self._attr_unique_id = f"{subentry_id}-actual_departure"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry_id)},
|
||||
name=coordinator.name,
|
||||
identifiers={(DOMAIN, self._subentry_id)},
|
||||
name=self._name,
|
||||
manufacturer=INTEGRATION_TITLE,
|
||||
model=ROUTE_MODEL,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
def name(self) -> str:
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | None:
|
||||
"""Return the native value of the sensor."""
|
||||
data = (
|
||||
self.coordinator.data.first_trip
|
||||
if not self.entity_description.is_next
|
||||
else self.coordinator.data.next_trip
|
||||
)
|
||||
if data is None:
|
||||
route_data = self.coordinator.data
|
||||
if not route_data.first_trip:
|
||||
return None
|
||||
|
||||
return self.entity_description.value_fn(data)
|
||||
first_trip = route_data.first_trip
|
||||
return _get_departure_time(first_trip)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the state attributes."""
|
||||
if self.entity_description.key != "actual_departure":
|
||||
return None
|
||||
|
||||
first_trip = self.coordinator.data.first_trip
|
||||
next_trip = self.coordinator.data.next_trip
|
||||
|
||||
@@ -317,12 +204,11 @@ class NSSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
|
||||
|
||||
status = first_trip.status
|
||||
|
||||
# Static attributes
|
||||
return {
|
||||
"going": first_trip.going,
|
||||
"departure_time_planned": _get_time_str(first_trip.departure_time_planned),
|
||||
"departure_time_actual": _get_time_str(first_trip.departure_time_actual),
|
||||
"departure_delay": get_delay(
|
||||
"departure_delay": _get_delay(
|
||||
first_trip.departure_time_planned,
|
||||
first_trip.departure_time_actual,
|
||||
),
|
||||
@@ -330,13 +216,13 @@ class NSSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
|
||||
"departure_platform_actual": first_trip.departure_platform_actual,
|
||||
"arrival_time_planned": _get_time_str(first_trip.arrival_time_planned),
|
||||
"arrival_time_actual": _get_time_str(first_trip.arrival_time_actual),
|
||||
"arrival_delay": get_delay(
|
||||
"arrival_delay": _get_delay(
|
||||
first_trip.arrival_time_planned,
|
||||
first_trip.arrival_time_actual,
|
||||
),
|
||||
"arrival_platform_planned": first_trip.arrival_platform_planned,
|
||||
"arrival_platform_actual": first_trip.arrival_platform_actual,
|
||||
"next": _get_time_str(get_departure_time(next_trip)),
|
||||
"next": _get_time_str(_get_departure_time(next_trip)),
|
||||
"status": status.lower() if status else None,
|
||||
"transfers": first_trip.nr_transfers,
|
||||
"route": _get_route(first_trip),
|
||||
|
||||
@@ -75,57 +75,6 @@
|
||||
"is_going": {
|
||||
"name": "Going"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"arrival_platform_actual": {
|
||||
"name": "Actual arrival platform"
|
||||
},
|
||||
"arrival_platform_planned": {
|
||||
"name": "Planned arrival platform"
|
||||
},
|
||||
"arrival_time_actual": {
|
||||
"name": "Actual arrival time"
|
||||
},
|
||||
"arrival_time_planned": {
|
||||
"name": "Planned arrival time"
|
||||
},
|
||||
"departure": {
|
||||
"name": "Departure"
|
||||
},
|
||||
"departure_platform_actual": {
|
||||
"name": "Actual departure platform"
|
||||
},
|
||||
"departure_platform_planned": {
|
||||
"name": "Planned departure platform"
|
||||
},
|
||||
"departure_time_actual": {
|
||||
"name": "Actual departure time"
|
||||
},
|
||||
"departure_time_planned": {
|
||||
"name": "Planned departure time"
|
||||
},
|
||||
"next_departure_time": {
|
||||
"name": "Next departure"
|
||||
},
|
||||
"route": {
|
||||
"name": "Route"
|
||||
},
|
||||
"route_from": {
|
||||
"name": "Route from"
|
||||
},
|
||||
"route_to": {
|
||||
"name": "Route to"
|
||||
},
|
||||
"status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"cancelled": "Cancelled",
|
||||
"normal": "Normal"
|
||||
}
|
||||
},
|
||||
"transfers": {
|
||||
"name": "Transfers"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -19,7 +19,6 @@ from google_nest_sdm.exceptions import (
|
||||
ConfigurationException,
|
||||
DecodeException,
|
||||
SubscriberException,
|
||||
SubscriberTimeoutException,
|
||||
)
|
||||
from google_nest_sdm.traits import TraitType
|
||||
import voluptuous as vol
|
||||
@@ -204,16 +203,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool
|
||||
await auth.async_get_access_token()
|
||||
except ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="reauth_required"
|
||||
) from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="auth_server_error"
|
||||
) from err
|
||||
raise ConfigEntryAuthFailed from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except ClientError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="auth_client_error"
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
subscriber = await api.new_subscriber(hass, entry, auth)
|
||||
if not subscriber:
|
||||
@@ -234,32 +227,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool
|
||||
unsub = await subscriber.start_async()
|
||||
except AuthException as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="reauth_required",
|
||||
f"Subscriber authentication error: {err!s}"
|
||||
) from err
|
||||
except ConfigurationException as err:
|
||||
_LOGGER.error("Configuration error: %s", err)
|
||||
return False
|
||||
except SubscriberTimeoutException as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="subscriber_timeout",
|
||||
) from err
|
||||
except SubscriberException as err:
|
||||
_LOGGER.error("Subscriber error: %s", err)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="subscriber_error",
|
||||
) from err
|
||||
raise ConfigEntryNotReady(f"Subscriber error: {err!s}") from err
|
||||
|
||||
try:
|
||||
device_manager = await subscriber.async_get_device_manager()
|
||||
except ApiException as err:
|
||||
unsub()
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_api_error",
|
||||
) from err
|
||||
raise ConfigEntryNotReady(f"Device manager error: {err!s}") from err
|
||||
|
||||
@callback
|
||||
def on_hass_stop(_: Event) -> None:
|
||||
|
||||
@@ -23,7 +23,12 @@ rules:
|
||||
entity-unique-id: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
test-before-setup: done
|
||||
test-before-setup:
|
||||
status: todo
|
||||
comment: |
|
||||
The integration does tests on setup, however the most common issues
|
||||
observed are related to ipv6 misconfigurations and the error messages
|
||||
are not self explanatory and can be improved.
|
||||
docs-high-level-description: done
|
||||
config-flow-test-coverage: done
|
||||
docs-actions: done
|
||||
|
||||
@@ -131,26 +131,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_client_error": {
|
||||
"message": "Client error during authentication, please check your network connection."
|
||||
},
|
||||
"auth_server_error": {
|
||||
"message": "Error response from authentication server, please see logs for details."
|
||||
},
|
||||
"device_api_error": {
|
||||
"message": "Error communicating with the Device Access API, please see logs for details."
|
||||
},
|
||||
"reauth_required": {
|
||||
"message": "Reauthentication is required, please follow the instructions in the UI to reauthenticate your account."
|
||||
},
|
||||
"subscriber_error": {
|
||||
"message": "Subscriber failed to connect to Google, please see logs for details."
|
||||
},
|
||||
"subscriber_timeout": {
|
||||
"message": "Subscriber timed out while attempting to connect to Google. Please check your network connection and IPv6 configuration if applicable."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"subscription_name": {
|
||||
"options": {
|
||||
|
||||
@@ -432,7 +432,7 @@ class NumberDeviceClass(StrEnum):
|
||||
|
||||
Unit of measurement: UnitOfVolumeFlowRate
|
||||
- SI / metric: `m³/h`, `m³/min`, `m³/s`, `L/h`, `L/min`, `L/s`, `mL/s`
|
||||
- USCS / imperial: `ft³/min`, `gal/min`, `gal/d`
|
||||
- USCS / imperial: `ft³/min`, `gal/min`
|
||||
"""
|
||||
|
||||
WATER = "water"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioonkyo"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioonkyo==0.4.0"],
|
||||
"requirements": ["aioonkyo==0.3.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -251,13 +251,7 @@ class PlaystationNetworkFriendDataCoordinator(
|
||||
def _update_data(self) -> PlaystationNetworkData:
|
||||
"""Update friend status data."""
|
||||
try:
|
||||
return PlaystationNetworkData(
|
||||
username=self.user.online_id,
|
||||
account_id=self.user.account_id,
|
||||
presence=self.user.get_presence(),
|
||||
profile=self.profile,
|
||||
trophy_summary=self.user.trophy_summary(),
|
||||
)
|
||||
presence = self.user.get_presence()
|
||||
except PSNAWPForbiddenError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -267,6 +261,19 @@ class PlaystationNetworkFriendDataCoordinator(
|
||||
except PSNAWPError:
|
||||
raise
|
||||
|
||||
try:
|
||||
trophy_summary = self.user.trophy_summary()
|
||||
except PSNAWPForbiddenError:
|
||||
trophy_summary = None
|
||||
|
||||
return PlaystationNetworkData(
|
||||
username=self.user.online_id,
|
||||
account_id=self.user.account_id,
|
||||
profile=self.profile,
|
||||
presence=presence,
|
||||
trophy_summary=trophy_summary,
|
||||
)
|
||||
|
||||
async def update_data(self) -> PlaystationNetworkData:
|
||||
"""Update friend status data."""
|
||||
return await self.hass.async_add_executor_job(self._update_data)
|
||||
|
||||
@@ -17,12 +17,7 @@ from .coordinator import PooldoseConfigEntry, PooldoseCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> bool:
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfVolume, UnitOfVolumeFlowRate
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfVolumeFlowRate
|
||||
|
||||
DOMAIN = "pooldose"
|
||||
MANUFACTURER = "SEKO"
|
||||
|
||||
# Mapping of device units (upper case) to Home Assistant units
|
||||
# Mapping of device units to Home Assistant units
|
||||
UNIT_MAPPING: dict[str, str] = {
|
||||
# Temperature units
|
||||
"°C": UnitOfTemperature.CELSIUS,
|
||||
"°F": UnitOfTemperature.FAHRENHEIT,
|
||||
# Volume flow rate units
|
||||
"M3/H": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
"L/S": UnitOfVolumeFlowRate.LITERS_PER_SECOND,
|
||||
# Volume units
|
||||
"L": UnitOfVolume.LITERS,
|
||||
"M3": UnitOfVolume.CUBIC_METERS,
|
||||
"m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
"L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND,
|
||||
}
|
||||
|
||||
@@ -68,35 +68,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"cl_target": {
|
||||
"default": "mdi:pool"
|
||||
},
|
||||
"ofa_cl_lower": {
|
||||
"default": "mdi:arrow-down-bold"
|
||||
},
|
||||
"ofa_cl_upper": {
|
||||
"default": "mdi:arrow-up-bold"
|
||||
},
|
||||
"ofa_orp_lower": {
|
||||
"default": "mdi:arrow-down-bold"
|
||||
},
|
||||
"ofa_orp_upper": {
|
||||
"default": "mdi:arrow-up-bold"
|
||||
},
|
||||
"ofa_ph_lower": {
|
||||
"default": "mdi:arrow-down-bold"
|
||||
},
|
||||
"ofa_ph_upper": {
|
||||
"default": "mdi:arrow-up-bold"
|
||||
},
|
||||
"orp_target": {
|
||||
"default": "mdi:water-check"
|
||||
},
|
||||
"ph_target": {
|
||||
"default": "mdi:ph"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cl": {
|
||||
"default": "mdi:pool"
|
||||
@@ -148,9 +119,6 @@
|
||||
},
|
||||
"ph_type_dosing": {
|
||||
"default": "mdi:beaker"
|
||||
},
|
||||
"water_meter_total_permanent": {
|
||||
"default": "mdi:counter"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/pooldose",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-pooldose==0.8.1"]
|
||||
"requirements": ["python-pooldose==0.7.8"]
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Number entities for the Seko PoolDose integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
EntityCategory,
|
||||
UnitOfElectricPotential,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PooldoseConfigEntry
|
||||
from .entity import PooldoseEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import PooldoseCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
NUMBER_DESCRIPTIONS: tuple[NumberEntityDescription, ...] = (
|
||||
NumberEntityDescription(
|
||||
key="ph_target",
|
||||
translation_key="ph_target",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.PH,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="orp_target",
|
||||
translation_key="orp_target",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="cl_target",
|
||||
translation_key="cl_target",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_ph_lower",
|
||||
translation_key="ofa_ph_lower",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.PH,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_ph_upper",
|
||||
translation_key="ofa_ph_upper",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.PH,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_orp_lower",
|
||||
translation_key="ofa_orp_lower",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_orp_upper",
|
||||
translation_key="ofa_orp_upper",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=NumberDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_cl_lower",
|
||||
translation_key="ofa_cl_lower",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="ofa_cl_upper",
|
||||
translation_key="ofa_cl_upper",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: PooldoseConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up PoolDose number entities from a config entry."""
|
||||
if TYPE_CHECKING:
|
||||
assert config_entry.unique_id is not None
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
number_data = coordinator.data.get("number", {})
|
||||
serial_number = config_entry.unique_id
|
||||
|
||||
async_add_entities(
|
||||
PooldoseNumber(coordinator, serial_number, coordinator.device_info, description)
|
||||
for description in NUMBER_DESCRIPTIONS
|
||||
if description.key in number_data
|
||||
)
|
||||
|
||||
|
||||
class PooldoseNumber(PooldoseEntity, NumberEntity):
|
||||
"""Number entity for the Seko PoolDose Python API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PooldoseCoordinator,
|
||||
serial_number: str,
|
||||
device_info: Any,
|
||||
description: NumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the number."""
|
||||
super().__init__(coordinator, serial_number, device_info, description, "number")
|
||||
self._async_update_attrs()
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._async_update_attrs()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
def _async_update_attrs(self) -> None:
|
||||
"""Update number attributes."""
|
||||
data = cast(dict, self.get_data())
|
||||
self._attr_native_value = data["value"]
|
||||
self._attr_native_min_value = data["min"]
|
||||
self._attr_native_max_value = data["max"]
|
||||
self._attr_native_step = data["step"]
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new value."""
|
||||
await self.coordinator.client.set_number(self.entity_description.key, value)
|
||||
self._attr_native_value = value
|
||||
self.async_write_ha_state()
|
||||
@@ -10,7 +10,6 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
@@ -59,13 +58,6 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
use_dynamic_unit=True,
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="water_meter_total_permanent",
|
||||
translation_key="water_meter_total_permanent",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
use_dynamic_unit=True,
|
||||
),
|
||||
PooldoseSensorEntityDescription(
|
||||
key="ph_type_dosing",
|
||||
translation_key="ph_type_dosing",
|
||||
@@ -231,8 +223,8 @@ class PooldoseSensor(PooldoseEntity, SensorEntity):
|
||||
and (data := self.get_data()) is not None
|
||||
and (device_unit := data.get("unit"))
|
||||
):
|
||||
# Map device unit (upper case) to Home Assistant unit, return None if unknown
|
||||
return UNIT_MAPPING.get(device_unit.upper())
|
||||
# Map device unit to Home Assistant unit, return None if unknown
|
||||
return UNIT_MAPPING.get(device_unit)
|
||||
|
||||
# Fall back to static unit from entity description
|
||||
return super().native_unit_of_measurement
|
||||
|
||||
@@ -68,35 +68,6 @@
|
||||
"name": "Auxiliary relay 3 status"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"cl_target": {
|
||||
"name": "Chlorine target"
|
||||
},
|
||||
"ofa_cl_lower": {
|
||||
"name": "Chlorine overfeed alarm lower limit"
|
||||
},
|
||||
"ofa_cl_upper": {
|
||||
"name": "Chlorine overfeed alarm upper limit"
|
||||
},
|
||||
"ofa_orp_lower": {
|
||||
"name": "ORP overfeed alarm lower limit"
|
||||
},
|
||||
"ofa_orp_upper": {
|
||||
"name": "ORP overfeed alarm upper limit"
|
||||
},
|
||||
"ofa_ph_lower": {
|
||||
"name": "pH overfeed alarm lower limit"
|
||||
},
|
||||
"ofa_ph_upper": {
|
||||
"name": "pH overfeed alarm upper limit"
|
||||
},
|
||||
"orp_target": {
|
||||
"name": "ORP target"
|
||||
},
|
||||
"ph_target": {
|
||||
"name": "pH target"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cl": {
|
||||
"name": "Chlorine"
|
||||
@@ -189,9 +160,6 @@
|
||||
"acid": "pH-",
|
||||
"alcalyne": "pH+"
|
||||
}
|
||||
},
|
||||
"water_meter_total_permanent": {
|
||||
"name": "Totalizer"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.16.6"]
|
||||
"requirements": ["reolink-aio==0.17.0"]
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==3.9.2",
|
||||
"python-roborock==3.9.3",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user