mirror of
https://github.com/home-assistant/core.git
synced 2025-11-23 01:36:57 +00:00
Compare commits
1 Commits
dev
...
context-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9767aa5e9b |
18
.github/workflows/builder.yml
vendored
18
.github/workflows/builder.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
|||||||
publish: ${{ steps.version.outputs.publish }}
|
publish: ${{ steps.version.outputs.publish }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -88,9 +88,13 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||||
|
exclude:
|
||||||
|
- arch: armv7
|
||||||
|
- arch: armhf
|
||||||
|
- arch: i386
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Download nightly wheels of frontend
|
- name: Download nightly wheels of frontend
|
||||||
if: needs.init.outputs.channel == 'dev'
|
if: needs.init.outputs.channel == 'dev'
|
||||||
@@ -223,7 +227,7 @@ jobs:
|
|||||||
- green
|
- green
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Set build additional args
|
- name: Set build additional args
|
||||||
run: |
|
run: |
|
||||||
@@ -261,7 +265,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Initialize git
|
- name: Initialize git
|
||||||
uses: home-assistant/actions/helpers/git-init@master
|
uses: home-assistant/actions/helpers/git-init@master
|
||||||
@@ -305,7 +309,7 @@ jobs:
|
|||||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||||
@@ -414,7 +418,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
@@ -459,7 +463,7 @@ jobs:
|
|||||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||||
|
|||||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -99,7 +99,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- &checkout
|
- &checkout
|
||||||
name: Check out code from GitHub
|
name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Generate partial Python venv restore key
|
- name: Generate partial Python venv restore key
|
||||||
id: generate_python_cache_key
|
id: generate_python_cache_key
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -21,14 +21,14 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code from GitHub
|
- name: Check out code from GitHub
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
|||||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||||
|
|||||||
40
.github/workflows/wheels.yml
vendored
40
.github/workflows/wheels.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- &checkout
|
- &checkout
|
||||||
name: Checkout the repository
|
name: Checkout the repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
id: python
|
id: python
|
||||||
@@ -77,8 +77,20 @@ jobs:
|
|||||||
|
|
||||||
# Use C-Extension for SQLAlchemy
|
# Use C-Extension for SQLAlchemy
|
||||||
echo "REQUIRE_SQLALCHEMY_CEXT=1"
|
echo "REQUIRE_SQLALCHEMY_CEXT=1"
|
||||||
|
|
||||||
|
# Add additional pip wheel build constraints
|
||||||
|
echo "PIP_CONSTRAINT=build_constraints.txt"
|
||||||
) > .env_file
|
) > .env_file
|
||||||
|
|
||||||
|
- name: Write pip wheel build constraints
|
||||||
|
run: |
|
||||||
|
(
|
||||||
|
# ninja 1.11.1.2 + 1.11.1.3 seem to be broken on at least armhf
|
||||||
|
# this caused the numpy builds to fail
|
||||||
|
# https://github.com/scikit-build/ninja-python-distributions/issues/274
|
||||||
|
echo "ninja==1.11.1.1"
|
||||||
|
) > build_constraints.txt
|
||||||
|
|
||||||
- name: Upload env_file
|
- name: Upload env_file
|
||||||
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
@@ -87,6 +99,13 @@ jobs:
|
|||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
|
||||||
|
- name: Upload build_constraints
|
||||||
|
uses: *actions-upload-artifact
|
||||||
|
with:
|
||||||
|
name: build_constraints
|
||||||
|
path: ./build_constraints.txt
|
||||||
|
overwrite: true
|
||||||
|
|
||||||
- name: Upload requirements_diff
|
- name: Upload requirements_diff
|
||||||
uses: *actions-upload-artifact
|
uses: *actions-upload-artifact
|
||||||
with:
|
with:
|
||||||
@@ -119,6 +138,13 @@ jobs:
|
|||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
- arch: aarch64
|
- arch: aarch64
|
||||||
os: ubuntu-24.04-arm
|
os: ubuntu-24.04-arm
|
||||||
|
exclude:
|
||||||
|
- abi: cp314
|
||||||
|
arch: armv7
|
||||||
|
- abi: cp314
|
||||||
|
arch: armhf
|
||||||
|
- abi: cp314
|
||||||
|
arch: i386
|
||||||
steps:
|
steps:
|
||||||
- *checkout
|
- *checkout
|
||||||
|
|
||||||
@@ -128,6 +154,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: env_file
|
name: env_file
|
||||||
|
|
||||||
|
- &download-build-constraints
|
||||||
|
name: Download build_constraints
|
||||||
|
uses: *actions-download-artifact
|
||||||
|
with:
|
||||||
|
name: build_constraints
|
||||||
|
|
||||||
- &download-requirements-diff
|
- &download-requirements-diff
|
||||||
name: Download requirements_diff
|
name: Download requirements_diff
|
||||||
uses: *actions-download-artifact
|
uses: *actions-download-artifact
|
||||||
@@ -167,7 +199,7 @@ jobs:
|
|||||||
- *checkout
|
- *checkout
|
||||||
|
|
||||||
- *download-env-file
|
- *download-env-file
|
||||||
|
- *download-build-constraints
|
||||||
- *download-requirements-diff
|
- *download-requirements-diff
|
||||||
|
|
||||||
- name: Download requirements_all_wheels
|
- name: Download requirements_all_wheels
|
||||||
@@ -177,6 +209,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Adjust build env
|
- name: Adjust build env
|
||||||
run: |
|
run: |
|
||||||
|
if [ "${{ matrix.arch }}" = "i386" ]; then
|
||||||
|
echo "NPY_DISABLE_SVML=1" >> .env_file
|
||||||
|
fi
|
||||||
|
|
||||||
# Do not pin numpy in wheels building
|
# Do not pin numpy in wheels building
|
||||||
sed -i "/numpy/d" homeassistant/package_constraints.txt
|
sed -i "/numpy/d" homeassistant/package_constraints.txt
|
||||||
# Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine
|
# Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ repos:
|
|||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
language: script
|
language: script
|
||||||
types: [text]
|
types: [text]
|
||||||
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
|
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(quality_scale)\.yaml|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
|
||||||
- id: hassfest-metadata
|
- id: hassfest-metadata
|
||||||
name: hassfest-metadata
|
name: hassfest-metadata
|
||||||
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
|
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ homeassistant.components.google_cloud.*
|
|||||||
homeassistant.components.google_drive.*
|
homeassistant.components.google_drive.*
|
||||||
homeassistant.components.google_photos.*
|
homeassistant.components.google_photos.*
|
||||||
homeassistant.components.google_sheets.*
|
homeassistant.components.google_sheets.*
|
||||||
homeassistant.components.google_weather.*
|
|
||||||
homeassistant.components.govee_ble.*
|
homeassistant.components.govee_ble.*
|
||||||
homeassistant.components.gpsd.*
|
homeassistant.components.gpsd.*
|
||||||
homeassistant.components.greeneye_monitor.*
|
homeassistant.components.greeneye_monitor.*
|
||||||
@@ -579,7 +578,6 @@ homeassistant.components.wiz.*
|
|||||||
homeassistant.components.wled.*
|
homeassistant.components.wled.*
|
||||||
homeassistant.components.workday.*
|
homeassistant.components.workday.*
|
||||||
homeassistant.components.worldclock.*
|
homeassistant.components.worldclock.*
|
||||||
homeassistant.components.xbox.*
|
|
||||||
homeassistant.components.xiaomi_ble.*
|
homeassistant.components.xiaomi_ble.*
|
||||||
homeassistant.components.yale_smart_alarm.*
|
homeassistant.components.yale_smart_alarm.*
|
||||||
homeassistant.components.yalexs_ble.*
|
homeassistant.components.yalexs_ble.*
|
||||||
|
|||||||
12
CODEOWNERS
generated
12
CODEOWNERS
generated
@@ -69,8 +69,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/airly/ @bieniu
|
/tests/components/airly/ @bieniu
|
||||||
/homeassistant/components/airnow/ @asymworks
|
/homeassistant/components/airnow/ @asymworks
|
||||||
/tests/components/airnow/ @asymworks
|
/tests/components/airnow/ @asymworks
|
||||||
/homeassistant/components/airobot/ @mettolen
|
|
||||||
/tests/components/airobot/ @mettolen
|
|
||||||
/homeassistant/components/airos/ @CoMPaTech
|
/homeassistant/components/airos/ @CoMPaTech
|
||||||
/tests/components/airos/ @CoMPaTech
|
/tests/components/airos/ @CoMPaTech
|
||||||
/homeassistant/components/airq/ @Sibgatulin @dl2080
|
/homeassistant/components/airq/ @Sibgatulin @dl2080
|
||||||
@@ -609,8 +607,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/google_tasks/ @allenporter
|
/tests/components/google_tasks/ @allenporter
|
||||||
/homeassistant/components/google_travel_time/ @eifinger
|
/homeassistant/components/google_travel_time/ @eifinger
|
||||||
/tests/components/google_travel_time/ @eifinger
|
/tests/components/google_travel_time/ @eifinger
|
||||||
/homeassistant/components/google_weather/ @tronikos
|
|
||||||
/tests/components/google_weather/ @tronikos
|
|
||||||
/homeassistant/components/govee_ble/ @bdraco
|
/homeassistant/components/govee_ble/ @bdraco
|
||||||
/tests/components/govee_ble/ @bdraco
|
/tests/components/govee_ble/ @bdraco
|
||||||
/homeassistant/components/govee_light_local/ @Galorhallen
|
/homeassistant/components/govee_light_local/ @Galorhallen
|
||||||
@@ -629,8 +625,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/guardian/ @bachya
|
/tests/components/guardian/ @bachya
|
||||||
/homeassistant/components/habitica/ @tr4nt0r
|
/homeassistant/components/habitica/ @tr4nt0r
|
||||||
/tests/components/habitica/ @tr4nt0r
|
/tests/components/habitica/ @tr4nt0r
|
||||||
/homeassistant/components/hanna/ @bestycame
|
|
||||||
/tests/components/hanna/ @bestycame
|
|
||||||
/homeassistant/components/hardkernel/ @home-assistant/core
|
/homeassistant/components/hardkernel/ @home-assistant/core
|
||||||
/tests/components/hardkernel/ @home-assistant/core
|
/tests/components/hardkernel/ @home-assistant/core
|
||||||
/homeassistant/components/hardware/ @home-assistant/core
|
/homeassistant/components/hardware/ @home-assistant/core
|
||||||
@@ -850,8 +844,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/kraken/ @eifinger
|
/tests/components/kraken/ @eifinger
|
||||||
/homeassistant/components/kulersky/ @emlove
|
/homeassistant/components/kulersky/ @emlove
|
||||||
/tests/components/kulersky/ @emlove
|
/tests/components/kulersky/ @emlove
|
||||||
/homeassistant/components/labs/ @home-assistant/core
|
|
||||||
/tests/components/labs/ @home-assistant/core
|
|
||||||
/homeassistant/components/lacrosse_view/ @IceBotYT
|
/homeassistant/components/lacrosse_view/ @IceBotYT
|
||||||
/tests/components/lacrosse_view/ @IceBotYT
|
/tests/components/lacrosse_view/ @IceBotYT
|
||||||
/homeassistant/components/lamarzocco/ @zweckj
|
/homeassistant/components/lamarzocco/ @zweckj
|
||||||
@@ -1382,8 +1374,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/sanix/ @tomaszsluszniak
|
/tests/components/sanix/ @tomaszsluszniak
|
||||||
/homeassistant/components/satel_integra/ @Tommatheussen
|
/homeassistant/components/satel_integra/ @Tommatheussen
|
||||||
/tests/components/satel_integra/ @Tommatheussen
|
/tests/components/satel_integra/ @Tommatheussen
|
||||||
/homeassistant/components/saunum/ @mettolen
|
|
||||||
/tests/components/saunum/ @mettolen
|
|
||||||
/homeassistant/components/scene/ @home-assistant/core
|
/homeassistant/components/scene/ @home-assistant/core
|
||||||
/tests/components/scene/ @home-assistant/core
|
/tests/components/scene/ @home-assistant/core
|
||||||
/homeassistant/components/schedule/ @home-assistant/core
|
/homeassistant/components/schedule/ @home-assistant/core
|
||||||
@@ -1742,8 +1732,6 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
|
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
|
||||||
/homeassistant/components/vicare/ @CFenner
|
/homeassistant/components/vicare/ @CFenner
|
||||||
/tests/components/vicare/ @CFenner
|
/tests/components/vicare/ @CFenner
|
||||||
/homeassistant/components/victron_ble/ @rajlaud
|
|
||||||
/tests/components/victron_ble/ @rajlaud
|
|
||||||
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
|
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
|
||||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||||
/homeassistant/components/vilfo/ @ManneW
|
/homeassistant/components/vilfo/ @ManneW
|
||||||
|
|||||||
4
Dockerfile
generated
4
Dockerfile
generated
@@ -21,9 +21,11 @@ ARG BUILD_ARCH
|
|||||||
RUN \
|
RUN \
|
||||||
case "${BUILD_ARCH}" in \
|
case "${BUILD_ARCH}" in \
|
||||||
"aarch64") go2rtc_suffix='arm64' ;; \
|
"aarch64") go2rtc_suffix='arm64' ;; \
|
||||||
|
"armhf") go2rtc_suffix='armv6' ;; \
|
||||||
|
"armv7") go2rtc_suffix='arm' ;; \
|
||||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||||
esac \
|
esac \
|
||||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.12/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||||
&& chmod +x /bin/go2rtc \
|
&& chmod +x /bin/go2rtc \
|
||||||
# Verify go2rtc can be executed
|
# Verify go2rtc can be executed
|
||||||
&& go2rtc --version
|
&& go2rtc --version
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||||
build_from:
|
build_from:
|
||||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
|
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1
|
||||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
|
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
|
||||||
|
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
|
||||||
|
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
|
||||||
|
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
|
||||||
cosign:
|
cosign:
|
||||||
base_identity: https://github.com/home-assistant/docker/.*
|
base_identity: https://github.com/home-assistant/docker/.*
|
||||||
identity: https://github.com/home-assistant/core/.*
|
identity: https://github.com/home-assistant/core/.*
|
||||||
|
|||||||
@@ -176,8 +176,6 @@ FRONTEND_INTEGRATIONS = {
|
|||||||
STAGE_0_INTEGRATIONS = (
|
STAGE_0_INTEGRATIONS = (
|
||||||
# Load logging and http deps as soon as possible
|
# Load logging and http deps as soon as possible
|
||||||
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
|
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
|
||||||
# Setup labs for preview features
|
|
||||||
("labs", {"labs"}, STAGE_0_SUBSTAGE_TIMEOUT),
|
|
||||||
# Setup frontend
|
# Setup frontend
|
||||||
("frontend", FRONTEND_INTEGRATIONS, None),
|
("frontend", FRONTEND_INTEGRATIONS, None),
|
||||||
# Setup recorder
|
# Setup recorder
|
||||||
@@ -214,7 +212,6 @@ DEFAULT_INTEGRATIONS = {
|
|||||||
"backup",
|
"backup",
|
||||||
"frontend",
|
"frontend",
|
||||||
"hardware",
|
"hardware",
|
||||||
"labs",
|
|
||||||
"logger",
|
"logger",
|
||||||
"network",
|
"network",
|
||||||
"system_health",
|
"system_health",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"google_tasks",
|
"google_tasks",
|
||||||
"google_translate",
|
"google_translate",
|
||||||
"google_travel_time",
|
"google_travel_time",
|
||||||
"google_weather",
|
|
||||||
"google_wifi",
|
"google_wifi",
|
||||||
"google",
|
"google",
|
||||||
"nest",
|
"nest",
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "victron",
|
|
||||||
"name": "Victron",
|
|
||||||
"integrations": ["victron_ble", "victron_remote_monitoring"]
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"""The Actron Air integration."""
|
"""The Actron Air integration."""
|
||||||
|
|
||||||
from actron_neo_api import (
|
from actron_neo_api import (
|
||||||
ActronAirACSystem,
|
ActronAirNeoACSystem,
|
||||||
ActronAirAPI,
|
ActronNeoAPI,
|
||||||
ActronAirAPIError,
|
ActronNeoAPIError,
|
||||||
ActronAirAuthError,
|
ActronNeoAuthError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from homeassistant.const import CONF_API_TOKEN, Platform
|
from homeassistant.const import CONF_API_TOKEN, Platform
|
||||||
@@ -23,16 +23,16 @@ PLATFORM = [Platform.CLIMATE]
|
|||||||
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
|
||||||
"""Set up Actron Air integration from a config entry."""
|
"""Set up Actron Air integration from a config entry."""
|
||||||
|
|
||||||
api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN])
|
api = ActronNeoAPI(refresh_token=entry.data[CONF_API_TOKEN])
|
||||||
systems: list[ActronAirACSystem] = []
|
systems: list[ActronAirNeoACSystem] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
systems = await api.get_ac_systems()
|
systems = await api.get_ac_systems()
|
||||||
await api.update_status()
|
await api.update_status()
|
||||||
except ActronAirAuthError:
|
except ActronNeoAuthError:
|
||||||
_LOGGER.error("Authentication error while setting up Actron Air integration")
|
_LOGGER.error("Authentication error while setting up Actron Air integration")
|
||||||
raise
|
raise
|
||||||
except ActronAirAPIError as err:
|
except ActronNeoAPIError as err:
|
||||||
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
|
_LOGGER.error("API error while setting up Actron Air integration: %s", err)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from actron_neo_api import ActronAirStatus, ActronAirZone
|
from actron_neo_api import ActronAirNeoStatus, ActronAirNeoZone
|
||||||
|
|
||||||
from homeassistant.components.climate import (
|
from homeassistant.components.climate import (
|
||||||
FAN_AUTO,
|
FAN_AUTO,
|
||||||
@@ -132,7 +132,7 @@ class ActronSystemClimate(BaseClimateEntity):
|
|||||||
return self._status.max_temp
|
return self._status.max_temp
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _status(self) -> ActronAirStatus:
|
def _status(self) -> ActronAirNeoStatus:
|
||||||
"""Get the current status from the coordinator."""
|
"""Get the current status from the coordinator."""
|
||||||
return self.coordinator.data
|
return self.coordinator.data
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ class ActronZoneClimate(BaseClimateEntity):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
coordinator: ActronAirSystemCoordinator,
|
coordinator: ActronAirSystemCoordinator,
|
||||||
zone: ActronAirZone,
|
zone: ActronAirNeoZone,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize an Actron Air unit."""
|
"""Initialize an Actron Air unit."""
|
||||||
super().__init__(coordinator, zone.title)
|
super().__init__(coordinator, zone.title)
|
||||||
@@ -221,7 +221,7 @@ class ActronZoneClimate(BaseClimateEntity):
|
|||||||
return self._zone.max_temp
|
return self._zone.max_temp
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _zone(self) -> ActronAirZone:
|
def _zone(self) -> ActronAirNeoZone:
|
||||||
"""Get the current zone data from the coordinator."""
|
"""Get the current zone data from the coordinator."""
|
||||||
status = self.coordinator.data
|
status = self.coordinator.data
|
||||||
return status.zones[self._zone_id]
|
return status.zones[self._zone_id]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from actron_neo_api import ActronAirAPI, ActronAirAuthError
|
from actron_neo_api import ActronNeoAPI, ActronNeoAuthError
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_API_TOKEN
|
from homeassistant.const import CONF_API_TOKEN
|
||||||
@@ -17,7 +17,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize the config flow."""
|
"""Initialize the config flow."""
|
||||||
self._api: ActronAirAPI | None = None
|
self._api: ActronNeoAPI | None = None
|
||||||
self._device_code: str | None = None
|
self._device_code: str | None = None
|
||||||
self._user_code: str = ""
|
self._user_code: str = ""
|
||||||
self._verification_uri: str = ""
|
self._verification_uri: str = ""
|
||||||
@@ -30,10 +30,10 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
if self._api is None:
|
if self._api is None:
|
||||||
_LOGGER.debug("Initiating device authorization")
|
_LOGGER.debug("Initiating device authorization")
|
||||||
self._api = ActronAirAPI()
|
self._api = ActronNeoAPI()
|
||||||
try:
|
try:
|
||||||
device_code_response = await self._api.request_device_code()
|
device_code_response = await self._api.request_device_code()
|
||||||
except ActronAirAuthError as err:
|
except ActronNeoAuthError as err:
|
||||||
_LOGGER.error("OAuth2 flow failed: %s", err)
|
_LOGGER.error("OAuth2 flow failed: %s", err)
|
||||||
return self.async_abort(reason="oauth2_error")
|
return self.async_abort(reason="oauth2_error")
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
try:
|
try:
|
||||||
await self._api.poll_for_token(self._device_code)
|
await self._api.poll_for_token(self._device_code)
|
||||||
_LOGGER.debug("Authorization successful")
|
_LOGGER.debug("Authorization successful")
|
||||||
except ActronAirAuthError as ex:
|
except ActronNeoAuthError as ex:
|
||||||
_LOGGER.exception("Error while waiting for device authorization")
|
_LOGGER.exception("Error while waiting for device authorization")
|
||||||
raise CannotConnect from ex
|
raise CannotConnect from ex
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
user_data = await self._api.get_user_info()
|
user_data = await self._api.get_user_info()
|
||||||
except ActronAirAuthError as err:
|
except ActronNeoAuthError as err:
|
||||||
_LOGGER.error("Error getting user info: %s", err)
|
_LOGGER.error("Error getting user info: %s", err)
|
||||||
return self.async_abort(reason="oauth2_error")
|
return self.async_abort(reason="oauth2_error")
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus
|
from actron_neo_api import ActronAirNeoACSystem, ActronAirNeoStatus, ActronNeoAPI
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@@ -23,7 +23,7 @@ ERROR_UNKNOWN = "unknown_error"
|
|||||||
class ActronAirRuntimeData:
|
class ActronAirRuntimeData:
|
||||||
"""Runtime data for the Actron Air integration."""
|
"""Runtime data for the Actron Air integration."""
|
||||||
|
|
||||||
api: ActronAirAPI
|
api: ActronNeoAPI
|
||||||
system_coordinators: dict[str, ActronAirSystemCoordinator]
|
system_coordinators: dict[str, ActronAirSystemCoordinator]
|
||||||
|
|
||||||
|
|
||||||
@@ -33,15 +33,15 @@ AUTH_ERROR_THRESHOLD = 3
|
|||||||
SCAN_INTERVAL = timedelta(seconds=30)
|
SCAN_INTERVAL = timedelta(seconds=30)
|
||||||
|
|
||||||
|
|
||||||
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirNeoACSystem]):
|
||||||
"""System coordinator for Actron Air integration."""
|
"""System coordinator for Actron Air integration."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ActronAirConfigEntry,
|
entry: ActronAirConfigEntry,
|
||||||
api: ActronAirAPI,
|
api: ActronNeoAPI,
|
||||||
system: ActronAirACSystem,
|
system: ActronAirNeoACSystem,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the coordinator."""
|
"""Initialize the coordinator."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -57,7 +57,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
|
|||||||
self.status = self.api.state_manager.get_status(self.serial_number)
|
self.status = self.api.state_manager.get_status(self.serial_number)
|
||||||
self.last_seen = dt_util.utcnow()
|
self.last_seen = dt_util.utcnow()
|
||||||
|
|
||||||
async def _async_update_data(self) -> ActronAirStatus:
|
async def _async_update_data(self) -> ActronAirNeoStatus:
|
||||||
"""Fetch updates and merge incremental changes into the full state."""
|
"""Fetch updates and merge incremental changes into the full state."""
|
||||||
await self.api.update_status()
|
await self.api.update_status()
|
||||||
self.status = self.api.state_manager.get_status(self.serial_number)
|
self.status = self.api.state_manager.get_status(self.serial_number)
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/actron_air",
|
"documentation": "https://www.home-assistant.io/integrations/actron_air",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["actron-neo-api==0.1.87"]
|
"requirements": ["actron-neo-api==0.1.84"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
|
|||||||
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
|
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
|
||||||
)
|
)
|
||||||
|
|
||||||
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
|
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
|
||||||
type AdGuardConfigEntry = ConfigEntry[AdGuardData]
|
type AdGuardConfigEntry = ConfigEntry[AdGuardData]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["adguardhome"],
|
"loggers": ["adguardhome"],
|
||||||
"requirements": ["adguardhome==0.8.1"]
|
"requirements": ["adguardhome==0.7.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
"""AdGuard Home Update platform."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from adguardhome import AdGuardHomeError
|
|
||||||
|
|
||||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
|
|
||||||
from . import AdGuardConfigEntry, AdGuardData
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .entity import AdGuardHomeEntity
|
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=300)
|
|
||||||
PARALLEL_UPDATES = 1
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: AdGuardConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up AdGuard Home update entity based on a config entry."""
|
|
||||||
data = entry.runtime_data
|
|
||||||
|
|
||||||
if (await data.client.update.update_available()).disabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
async_add_entities([AdGuardHomeUpdate(data, entry)], True)
|
|
||||||
|
|
||||||
|
|
||||||
class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
|
|
||||||
"""Defines an AdGuard Home update."""
|
|
||||||
|
|
||||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
|
||||||
_attr_name = None
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
data: AdGuardData,
|
|
||||||
entry: AdGuardConfigEntry,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize AdGuard Home update."""
|
|
||||||
super().__init__(data, entry)
|
|
||||||
|
|
||||||
self._attr_unique_id = "_".join(
|
|
||||||
[DOMAIN, self.adguard.host, str(self.adguard.port), "update"]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _adguard_update(self) -> None:
|
|
||||||
"""Update AdGuard Home entity."""
|
|
||||||
value = await self.adguard.update.update_available()
|
|
||||||
self._attr_installed_version = self.data.version
|
|
||||||
self._attr_latest_version = value.new_version
|
|
||||||
self._attr_release_summary = value.announcement
|
|
||||||
self._attr_release_url = value.announcement_url
|
|
||||||
|
|
||||||
async def async_install(
|
|
||||||
self, version: str | None, backup: bool, **kwargs: Any
|
|
||||||
) -> None:
|
|
||||||
"""Install latest update."""
|
|
||||||
try:
|
|
||||||
await self.adguard.update.begin_update()
|
|
||||||
except AdGuardHomeError as err:
|
|
||||||
raise HomeAssistantError(f"Failed to install update: {err}") from err
|
|
||||||
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""The Airobot integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
|
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.CLIMATE]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:
|
|
||||||
"""Set up Airobot from a config entry."""
|
|
||||||
coordinator = AirobotDataUpdateCoordinator(hass, entry)
|
|
||||||
|
|
||||||
# Fetch initial data so we have data when entities subscribe
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
|
||||||
|
|
||||||
entry.runtime_data = coordinator
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:
|
|
||||||
"""Unload a config entry."""
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"""Climate platform for Airobot thermostat."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pyairobotrest.const import (
|
|
||||||
MODE_AWAY,
|
|
||||||
MODE_HOME,
|
|
||||||
SETPOINT_TEMP_MAX,
|
|
||||||
SETPOINT_TEMP_MIN,
|
|
||||||
)
|
|
||||||
from pyairobotrest.exceptions import AirobotError
|
|
||||||
from pyairobotrest.models import ThermostatSettings, ThermostatStatus
|
|
||||||
|
|
||||||
from homeassistant.components.climate import (
|
|
||||||
PRESET_AWAY,
|
|
||||||
PRESET_BOOST,
|
|
||||||
PRESET_HOME,
|
|
||||||
ClimateEntity,
|
|
||||||
ClimateEntityFeature,
|
|
||||||
HVACAction,
|
|
||||||
HVACMode,
|
|
||||||
)
|
|
||||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import ServiceValidationError
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
|
|
||||||
from . import AirobotConfigEntry
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .entity import AirobotEntity
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
|
||||||
|
|
||||||
_PRESET_MODE_2_MODE = {
|
|
||||||
PRESET_AWAY: MODE_AWAY,
|
|
||||||
PRESET_HOME: MODE_HOME,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: AirobotConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Airobot climate platform."""
|
|
||||||
coordinator = entry.runtime_data
|
|
||||||
async_add_entities([AirobotClimate(coordinator)])
|
|
||||||
|
|
||||||
|
|
||||||
class AirobotClimate(AirobotEntity, ClimateEntity):
|
|
||||||
"""Representation of an Airobot thermostat."""
|
|
||||||
|
|
||||||
_attr_name = None
|
|
||||||
_attr_translation_key = "thermostat"
|
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
|
||||||
_attr_hvac_modes = [HVACMode.HEAT]
|
|
||||||
_attr_preset_modes = [PRESET_HOME, PRESET_AWAY, PRESET_BOOST]
|
|
||||||
_attr_supported_features = (
|
|
||||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
|
|
||||||
)
|
|
||||||
_attr_min_temp = SETPOINT_TEMP_MIN
|
|
||||||
_attr_max_temp = SETPOINT_TEMP_MAX
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _status(self) -> ThermostatStatus:
|
|
||||||
"""Get status from coordinator data."""
|
|
||||||
return self.coordinator.data.status
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _settings(self) -> ThermostatSettings:
|
|
||||||
"""Get settings from coordinator data."""
|
|
||||||
return self.coordinator.data.settings
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_temperature(self) -> float | None:
|
|
||||||
"""Return the current temperature."""
|
|
||||||
return self._status.temp_air
|
|
||||||
|
|
||||||
@property
|
|
||||||
def target_temperature(self) -> float | None:
|
|
||||||
"""Return the target temperature."""
|
|
||||||
if self._settings.is_home_mode:
|
|
||||||
return self._settings.setpoint_temp
|
|
||||||
return self._settings.setpoint_temp_away
|
|
||||||
|
|
||||||
@property
|
|
||||||
def hvac_mode(self) -> HVACMode:
|
|
||||||
"""Return current HVAC mode."""
|
|
||||||
if self._status.is_heating:
|
|
||||||
return HVACMode.HEAT
|
|
||||||
return HVACMode.OFF
|
|
||||||
|
|
||||||
@property
|
|
||||||
def hvac_action(self) -> HVACAction:
|
|
||||||
"""Return current HVAC action."""
|
|
||||||
if self._status.is_heating:
|
|
||||||
return HVACAction.HEATING
|
|
||||||
return HVACAction.IDLE
|
|
||||||
|
|
||||||
@property
|
|
||||||
def preset_mode(self) -> str | None:
|
|
||||||
"""Return current preset mode."""
|
|
||||||
if self._settings.setting_flags.boost_enabled:
|
|
||||||
return PRESET_BOOST
|
|
||||||
if self._settings.is_home_mode:
|
|
||||||
return PRESET_HOME
|
|
||||||
return PRESET_AWAY
|
|
||||||
|
|
||||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
|
||||||
"""Set new target temperature."""
|
|
||||||
temperature = kwargs[ATTR_TEMPERATURE]
|
|
||||||
|
|
||||||
try:
|
|
||||||
if self._settings.is_home_mode:
|
|
||||||
await self.coordinator.client.set_home_temperature(float(temperature))
|
|
||||||
else:
|
|
||||||
await self.coordinator.client.set_away_temperature(float(temperature))
|
|
||||||
except AirobotError as err:
|
|
||||||
raise ServiceValidationError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="set_temperature_failed",
|
|
||||||
translation_placeholders={"temperature": str(temperature)},
|
|
||||||
) from err
|
|
||||||
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
|
|
||||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
|
||||||
"""Set new preset mode."""
|
|
||||||
try:
|
|
||||||
if preset_mode == PRESET_BOOST:
|
|
||||||
# Enable boost mode
|
|
||||||
if not self._settings.setting_flags.boost_enabled:
|
|
||||||
await self.coordinator.client.set_boost_mode(True)
|
|
||||||
else:
|
|
||||||
# Disable boost mode if it's enabled
|
|
||||||
if self._settings.setting_flags.boost_enabled:
|
|
||||||
await self.coordinator.client.set_boost_mode(False)
|
|
||||||
|
|
||||||
# Set the mode (HOME or AWAY)
|
|
||||||
await self.coordinator.client.set_mode(_PRESET_MODE_2_MODE[preset_mode])
|
|
||||||
|
|
||||||
except AirobotError as err:
|
|
||||||
raise ServiceValidationError(
|
|
||||||
translation_domain=DOMAIN,
|
|
||||||
translation_key="set_preset_mode_failed",
|
|
||||||
translation_placeholders={"preset_mode": preset_mode},
|
|
||||||
) from err
|
|
||||||
|
|
||||||
await self.coordinator.async_request_refresh()
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"""Config flow for the Airobot integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pyairobotrest import AirobotClient
|
|
||||||
from pyairobotrest.exceptions import (
|
|
||||||
AirobotAuthError,
|
|
||||||
AirobotConnectionError,
|
|
||||||
AirobotError,
|
|
||||||
AirobotTimeoutError,
|
|
||||||
)
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow as BaseConfigFlow, ConfigFlowResult
|
|
||||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_USERNAME
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_HOST): str,
|
|
||||||
vol.Required(CONF_USERNAME): str,
|
|
||||||
vol.Required(CONF_PASSWORD): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeviceInfo:
|
|
||||||
"""Device information."""
|
|
||||||
|
|
||||||
title: str
|
|
||||||
device_id: str
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> DeviceInfo:
|
|
||||||
"""Validate the user input allows us to connect.
|
|
||||||
|
|
||||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
|
||||||
"""
|
|
||||||
session = async_get_clientsession(hass)
|
|
||||||
|
|
||||||
client = AirobotClient(
|
|
||||||
host=data[CONF_HOST],
|
|
||||||
username=data[CONF_USERNAME],
|
|
||||||
password=data[CONF_PASSWORD],
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try to fetch data to validate connection and authentication
|
|
||||||
status = await client.get_statuses()
|
|
||||||
settings = await client.get_settings()
|
|
||||||
except AirobotAuthError as err:
|
|
||||||
raise InvalidAuth from err
|
|
||||||
except (AirobotConnectionError, AirobotTimeoutError, AirobotError) as err:
|
|
||||||
raise CannotConnect from err
|
|
||||||
|
|
||||||
# Use device name or device ID as title
|
|
||||||
title = settings.device_name or status.device_id
|
|
||||||
|
|
||||||
return DeviceInfo(title=title, device_id=status.device_id)
|
|
||||||
|
|
||||||
|
|
||||||
class AirobotConfigFlow(BaseConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Airobot."""
|
|
||||||
|
|
||||||
VERSION = 1
|
|
||||||
MINOR_VERSION = 1
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Initialize the config flow."""
|
|
||||||
self._discovered_host: str | None = None
|
|
||||||
self._discovered_mac: str | None = None
|
|
||||||
self._discovered_device_id: str | None = None
|
|
||||||
|
|
||||||
async def async_step_dhcp(
|
|
||||||
self, discovery_info: DhcpServiceInfo
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle DHCP discovery."""
|
|
||||||
# Store the discovered IP address and MAC
|
|
||||||
self._discovered_host = discovery_info.ip
|
|
||||||
self._discovered_mac = discovery_info.macaddress
|
|
||||||
|
|
||||||
# Extract device_id from hostname (format: airobot-thermostat-t01xxxxxx)
|
|
||||||
hostname = discovery_info.hostname.lower()
|
|
||||||
device_id = hostname.replace("airobot-thermostat-", "").upper()
|
|
||||||
self._discovered_device_id = device_id
|
|
||||||
# Set unique_id to device_id for duplicate detection
|
|
||||||
await self.async_set_unique_id(device_id)
|
|
||||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
|
|
||||||
|
|
||||||
# Show the confirmation form
|
|
||||||
return await self.async_step_dhcp_confirm()
|
|
||||||
|
|
||||||
async def async_step_dhcp_confirm(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle DHCP discovery confirmation - ask for credentials only."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
|
|
||||||
if user_input is not None:
|
|
||||||
# Combine discovered host and device_id with user-provided password
|
|
||||||
data = {
|
|
||||||
CONF_HOST: self._discovered_host,
|
|
||||||
CONF_USERNAME: self._discovered_device_id,
|
|
||||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
info = 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:
|
|
||||||
# Store MAC address in config entry data
|
|
||||||
if self._discovered_mac:
|
|
||||||
data[CONF_MAC] = self._discovered_mac
|
|
||||||
|
|
||||||
return self.async_create_entry(title=info.title, data=data)
|
|
||||||
|
|
||||||
# Only ask for password since we already have the device_id from discovery
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="dhcp_confirm",
|
|
||||||
data_schema=vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_PASSWORD): str,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
description_placeholders={
|
|
||||||
"host": self._discovered_host or "",
|
|
||||||
"device_id": self._discovered_device_id or "",
|
|
||||||
},
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
|
||||||
try:
|
|
||||||
info = await validate_input(self.hass, user_input)
|
|
||||||
except CannotConnect:
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
except InvalidAuth:
|
|
||||||
errors["base"] = "invalid_auth"
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Unexpected exception")
|
|
||||||
errors["base"] = "unknown"
|
|
||||||
else:
|
|
||||||
# Use device ID as unique ID to prevent duplicates
|
|
||||||
await self.async_set_unique_id(info.device_id)
|
|
||||||
self._abort_if_unique_id_configured()
|
|
||||||
return self.async_create_entry(title=info.title, data=user_input)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CannotConnect(HomeAssistantError):
|
|
||||||
"""Error to indicate we cannot connect."""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidAuth(HomeAssistantError):
|
|
||||||
"""Error to indicate there is invalid auth."""
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""Constants for the Airobot integration."""
|
|
||||||
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
DOMAIN: Final = "airobot"
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Coordinator for the Airobot integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from pyairobotrest import AirobotClient
|
|
||||||
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.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .models import AirobotData
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Update interval - thermostat measures air every 30 seconds
|
|
||||||
UPDATE_INTERVAL = timedelta(seconds=30)
|
|
||||||
|
|
||||||
type AirobotConfigEntry = ConfigEntry[AirobotDataUpdateCoordinator]
|
|
||||||
|
|
||||||
|
|
||||||
class AirobotDataUpdateCoordinator(DataUpdateCoordinator[AirobotData]):
|
|
||||||
"""Class to manage fetching Airobot data."""
|
|
||||||
|
|
||||||
config_entry: AirobotConfigEntry
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, entry: AirobotConfigEntry) -> None:
|
|
||||||
"""Initialize the coordinator."""
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
name=DOMAIN,
|
|
||||||
update_interval=UPDATE_INTERVAL,
|
|
||||||
config_entry=entry,
|
|
||||||
)
|
|
||||||
session = async_get_clientsession(hass)
|
|
||||||
|
|
||||||
self.client = AirobotClient(
|
|
||||||
host=entry.data[CONF_HOST],
|
|
||||||
username=entry.data[CONF_USERNAME],
|
|
||||||
password=entry.data[CONF_PASSWORD],
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> AirobotData:
|
|
||||||
"""Fetch data from API endpoint."""
|
|
||||||
try:
|
|
||||||
status = await self.client.get_statuses()
|
|
||||||
settings = await self.client.get_settings()
|
|
||||||
except (AirobotAuthError, AirobotConnectionError) as err:
|
|
||||||
raise UpdateFailed(f"Failed to communicate with device: {err}") from err
|
|
||||||
|
|
||||||
return AirobotData(status=status, settings=settings)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Base entity for Airobot integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from homeassistant.const import CONF_MAC
|
|
||||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .coordinator import AirobotDataUpdateCoordinator
|
|
||||||
|
|
||||||
|
|
||||||
class AirobotEntity(CoordinatorEntity[AirobotDataUpdateCoordinator]):
|
|
||||||
"""Base class for Airobot entities."""
|
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
coordinator: AirobotDataUpdateCoordinator,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the entity."""
|
|
||||||
super().__init__(coordinator)
|
|
||||||
status = coordinator.data.status
|
|
||||||
settings = coordinator.data.settings
|
|
||||||
|
|
||||||
self._attr_unique_id = status.device_id
|
|
||||||
|
|
||||||
connections = set()
|
|
||||||
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
|
|
||||||
connections.add((CONNECTION_NETWORK_MAC, mac))
|
|
||||||
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, status.device_id)},
|
|
||||||
connections=connections,
|
|
||||||
name=settings.device_name or status.device_id,
|
|
||||||
manufacturer="Airobot",
|
|
||||||
model="Thermostat",
|
|
||||||
model_id="TE1",
|
|
||||||
sw_version=str(status.fw_version),
|
|
||||||
hw_version=str(status.hw_version),
|
|
||||||
)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "airobot",
|
|
||||||
"name": "Airobot",
|
|
||||||
"codeowners": ["@mettolen"],
|
|
||||||
"config_flow": true,
|
|
||||||
"dhcp": [
|
|
||||||
{
|
|
||||||
"hostname": "airobot-thermostat-*"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/airobot",
|
|
||||||
"integration_type": "device",
|
|
||||||
"iot_class": "local_polling",
|
|
||||||
"loggers": ["pyairobotrest"],
|
|
||||||
"quality_scale": "bronze",
|
|
||||||
"requirements": ["pyairobotrest==0.1.0"]
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
"""Models for the Airobot integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from pyairobotrest.models import ThermostatSettings, ThermostatStatus
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AirobotData:
|
|
||||||
"""Data from the Airobot coordinator."""
|
|
||||||
|
|
||||||
status: ThermostatStatus
|
|
||||||
settings: ThermostatSettings
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
rules:
|
|
||||||
# Bronze
|
|
||||||
action-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: Integration does not register custom actions.
|
|
||||||
appropriate-polling: done
|
|
||||||
brands: done
|
|
||||||
common-modules: done
|
|
||||||
config-flow-test-coverage: done
|
|
||||||
config-flow: done
|
|
||||||
dependency-transparency: done
|
|
||||||
docs-actions:
|
|
||||||
status: exempt
|
|
||||||
comment: Integration does not register custom actions.
|
|
||||||
docs-high-level-description: done
|
|
||||||
docs-installation-instructions: done
|
|
||||||
docs-removal-instructions: done
|
|
||||||
entity-event-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: Integration does not use event subscriptions.
|
|
||||||
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: done
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable: done
|
|
||||||
integration-owner: done
|
|
||||||
log-when-unavailable: done
|
|
||||||
parallel-updates: done
|
|
||||||
reauthentication-flow: todo
|
|
||||||
test-coverage: done
|
|
||||||
|
|
||||||
# Gold
|
|
||||||
devices: done
|
|
||||||
diagnostics: todo
|
|
||||||
discovery-update-info: done
|
|
||||||
discovery: done
|
|
||||||
docs-data-update: done
|
|
||||||
docs-examples: todo
|
|
||||||
docs-known-limitations: todo
|
|
||||||
docs-supported-devices: done
|
|
||||||
docs-supported-functions: done
|
|
||||||
docs-troubleshooting: done
|
|
||||||
docs-use-cases: todo
|
|
||||||
dynamic-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: Single device integration, no dynamic device discovery needed.
|
|
||||||
entity-category: done
|
|
||||||
entity-device-class: done
|
|
||||||
entity-disabled-by-default: todo
|
|
||||||
entity-translations: todo
|
|
||||||
exception-translations: done
|
|
||||||
icon-translations: todo
|
|
||||||
reconfiguration-flow: todo
|
|
||||||
repair-issues: todo
|
|
||||||
stale-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: Single device integration, no stale device handling needed.
|
|
||||||
|
|
||||||
# Platinum
|
|
||||||
async-dependency: done
|
|
||||||
inject-websession: done
|
|
||||||
strict-typing: todo
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
|
||||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
|
||||||
},
|
|
||||||
"step": {
|
|
||||||
"dhcp_confirm": {
|
|
||||||
"data": {
|
|
||||||
"password": "[%key:common::config_flow::data::password%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"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."
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"data": {
|
|
||||||
"host": "[%key:common::config_flow::data::host%]",
|
|
||||||
"password": "[%key:common::config_flow::data::password%]",
|
|
||||||
"username": "[%key:common::config_flow::data::username%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"host": "The hostname or IP address of your Airobot thermostat.",
|
|
||||||
"password": "The thermostat password.",
|
|
||||||
"username": "The thermostat Device ID (e.g., T01XXXXXX)."
|
|
||||||
},
|
|
||||||
"description": "Enter your Airobot thermostat connection details. Find the Device ID and password in the thermostat settings menu under Connectivity → Mobile app."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exceptions": {
|
|
||||||
"set_preset_mode_failed": {
|
|
||||||
"message": "Failed to set preset mode to {preset_mode}."
|
|
||||||
},
|
|
||||||
"set_temperature_failed": {
|
|
||||||
"message": "Failed to set temperature to {temperature}."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers import device_registry as dr
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.debounce import Debouncer
|
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
|
||||||
@@ -44,9 +43,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
|||||||
name=entry.title,
|
name=entry.title,
|
||||||
config_entry=entry,
|
config_entry=entry,
|
||||||
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
update_interval=timedelta(seconds=SCAN_INTERVAL),
|
||||||
request_refresh_debouncer=Debouncer(
|
|
||||||
hass, _LOGGER, cooldown=30, immediate=False
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
self.api = AmazonEchoApi(
|
self.api = AmazonEchoApi(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import Event, HassJob, HomeAssistant, callback
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ from .analytics import (
|
|||||||
EntityAnalyticsModifications,
|
EntityAnalyticsModifications,
|
||||||
async_devices_payload,
|
async_devices_payload,
|
||||||
)
|
)
|
||||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, PREFERENCE_SCHEMA
|
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||||
from .http import AnalyticsDevicesView
|
from .http import AnalyticsDevicesView
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -42,9 +43,28 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
|
|||||||
# Load stored data
|
# Load stored data
|
||||||
await analytics.load()
|
await analytics.load()
|
||||||
|
|
||||||
async def start_schedule(_event: Event) -> None:
|
@callback
|
||||||
|
def start_schedule(_event: Event) -> None:
|
||||||
"""Start the send schedule after the started event."""
|
"""Start the send schedule after the started event."""
|
||||||
await analytics.async_schedule()
|
# Wait 15 min after started
|
||||||
|
async_call_later(
|
||||||
|
hass,
|
||||||
|
900,
|
||||||
|
HassJob(
|
||||||
|
analytics.send_analytics,
|
||||||
|
name="analytics schedule",
|
||||||
|
cancel_on_shutdown=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send every day
|
||||||
|
async_track_time_interval(
|
||||||
|
hass,
|
||||||
|
analytics.send_analytics,
|
||||||
|
INTERVAL,
|
||||||
|
name="analytics daily",
|
||||||
|
cancel_on_shutdown=True,
|
||||||
|
)
|
||||||
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||||
|
|
||||||
@@ -91,7 +111,7 @@ async def websocket_analytics_preferences(
|
|||||||
analytics = hass.data[DATA_COMPONENT]
|
analytics = hass.data[DATA_COMPONENT]
|
||||||
|
|
||||||
await analytics.save_preferences(preferences)
|
await analytics.save_preferences(preferences)
|
||||||
await analytics.async_schedule()
|
await analytics.send_analytics()
|
||||||
|
|
||||||
connection.send_result(
|
connection.send_result(
|
||||||
msg["id"],
|
msg["id"],
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ from asyncio import timeout
|
|||||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||||
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import random
|
|
||||||
import time
|
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -33,18 +31,10 @@ from homeassistant.const import (
|
|||||||
BASE_PLATFORMS,
|
BASE_PLATFORMS,
|
||||||
__version__ as HA_VERSION,
|
__version__ as HA_VERSION,
|
||||||
)
|
)
|
||||||
from homeassistant.core import (
|
from homeassistant.core import HomeAssistant, callback
|
||||||
CALLBACK_TYPE,
|
|
||||||
HassJob,
|
|
||||||
HomeAssistant,
|
|
||||||
ReleaseChannel,
|
|
||||||
callback,
|
|
||||||
get_release_channel,
|
|
||||||
)
|
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.singleton import singleton
|
from homeassistant.helpers.singleton import singleton
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
@@ -61,7 +51,6 @@ from homeassistant.setup import async_get_loaded_integrations
|
|||||||
from .const import (
|
from .const import (
|
||||||
ANALYTICS_ENDPOINT_URL,
|
ANALYTICS_ENDPOINT_URL,
|
||||||
ANALYTICS_ENDPOINT_URL_DEV,
|
ANALYTICS_ENDPOINT_URL_DEV,
|
||||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
|
||||||
ATTR_ADDON_COUNT,
|
ATTR_ADDON_COUNT,
|
||||||
ATTR_ADDONS,
|
ATTR_ADDONS,
|
||||||
ATTR_ARCH,
|
ATTR_ARCH,
|
||||||
@@ -82,7 +71,6 @@ from .const import (
|
|||||||
ATTR_PROTECTED,
|
ATTR_PROTECTED,
|
||||||
ATTR_RECORDER,
|
ATTR_RECORDER,
|
||||||
ATTR_SLUG,
|
ATTR_SLUG,
|
||||||
ATTR_SNAPSHOTS,
|
|
||||||
ATTR_STATE_COUNT,
|
ATTR_STATE_COUNT,
|
||||||
ATTR_STATISTICS,
|
ATTR_STATISTICS,
|
||||||
ATTR_SUPERVISOR,
|
ATTR_SUPERVISOR,
|
||||||
@@ -92,10 +80,8 @@ from .const import (
|
|||||||
ATTR_UUID,
|
ATTR_UUID,
|
||||||
ATTR_VERSION,
|
ATTR_VERSION,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
INTERVAL,
|
|
||||||
LOGGER,
|
LOGGER,
|
||||||
PREFERENCE_SCHEMA,
|
PREFERENCE_SCHEMA,
|
||||||
SNAPSHOT_VERSION,
|
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
STORAGE_VERSION,
|
STORAGE_VERSION,
|
||||||
)
|
)
|
||||||
@@ -208,18 +194,13 @@ def gen_uuid() -> str:
|
|||||||
return uuid.uuid4().hex
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
RELEASE_CHANNEL = get_release_channel()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AnalyticsData:
|
class AnalyticsData:
|
||||||
"""Analytics data."""
|
"""Analytics data."""
|
||||||
|
|
||||||
onboarded: bool
|
onboarded: bool
|
||||||
preferences: dict[str, bool]
|
preferences: dict[str, bool]
|
||||||
uuid: str | None = None
|
uuid: str | None
|
||||||
submission_identifier: str | None = None
|
|
||||||
snapshot_submission_time: float | None = None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> AnalyticsData:
|
def from_dict(cls, data: dict[str, Any]) -> AnalyticsData:
|
||||||
@@ -228,8 +209,6 @@ class AnalyticsData:
|
|||||||
data["onboarded"],
|
data["onboarded"],
|
||||||
data["preferences"],
|
data["preferences"],
|
||||||
data["uuid"],
|
data["uuid"],
|
||||||
data.get("submission_identifier"),
|
|
||||||
data.get("snapshot_submission_time"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -240,10 +219,8 @@ class Analytics:
|
|||||||
"""Initialize the Analytics class."""
|
"""Initialize the Analytics class."""
|
||||||
self.hass: HomeAssistant = hass
|
self.hass: HomeAssistant = hass
|
||||||
self.session = async_get_clientsession(hass)
|
self.session = async_get_clientsession(hass)
|
||||||
self._data = AnalyticsData(False, {})
|
self._data = AnalyticsData(False, {}, None)
|
||||||
self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
|
self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
|
||||||
self._basic_scheduled: CALLBACK_TYPE | None = None
|
|
||||||
self._snapshot_scheduled: CALLBACK_TYPE | None = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def preferences(self) -> dict:
|
def preferences(self) -> dict:
|
||||||
@@ -251,7 +228,6 @@ class Analytics:
|
|||||||
preferences = self._data.preferences
|
preferences = self._data.preferences
|
||||||
return {
|
return {
|
||||||
ATTR_BASE: preferences.get(ATTR_BASE, False),
|
ATTR_BASE: preferences.get(ATTR_BASE, False),
|
||||||
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
|
|
||||||
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
|
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
|
||||||
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
|
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
|
||||||
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
|
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
|
||||||
@@ -268,9 +244,9 @@ class Analytics:
|
|||||||
return self._data.uuid
|
return self._data.uuid
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def endpoint_basic(self) -> str:
|
def endpoint(self) -> str:
|
||||||
"""Return the endpoint that will receive the payload."""
|
"""Return the endpoint that will receive the payload."""
|
||||||
if RELEASE_CHANNEL is ReleaseChannel.DEV:
|
if HA_VERSION.endswith("0.dev0"):
|
||||||
# dev installations will contact the dev analytics environment
|
# dev installations will contact the dev analytics environment
|
||||||
return ANALYTICS_ENDPOINT_URL_DEV
|
return ANALYTICS_ENDPOINT_URL_DEV
|
||||||
return ANALYTICS_ENDPOINT_URL
|
return ANALYTICS_ENDPOINT_URL
|
||||||
@@ -301,17 +277,13 @@ class Analytics:
|
|||||||
):
|
):
|
||||||
self._data.preferences[ATTR_DIAGNOSTICS] = False
|
self._data.preferences[ATTR_DIAGNOSTICS] = False
|
||||||
|
|
||||||
async def _save(self) -> None:
|
|
||||||
"""Save data."""
|
|
||||||
await self._store.async_save(dataclass_asdict(self._data))
|
|
||||||
|
|
||||||
async def save_preferences(self, preferences: dict) -> None:
|
async def save_preferences(self, preferences: dict) -> None:
|
||||||
"""Save preferences."""
|
"""Save preferences."""
|
||||||
preferences = PREFERENCE_SCHEMA(preferences)
|
preferences = PREFERENCE_SCHEMA(preferences)
|
||||||
self._data.preferences.update(preferences)
|
self._data.preferences.update(preferences)
|
||||||
self._data.onboarded = True
|
self._data.onboarded = True
|
||||||
|
|
||||||
await self._save()
|
await self._store.async_save(dataclass_asdict(self._data))
|
||||||
|
|
||||||
if self.supervisor:
|
if self.supervisor:
|
||||||
await hassio.async_update_diagnostics(
|
await hassio.async_update_diagnostics(
|
||||||
@@ -320,16 +292,17 @@ class Analytics:
|
|||||||
|
|
||||||
async def send_analytics(self, _: datetime | None = None) -> None:
|
async def send_analytics(self, _: datetime | None = None) -> None:
|
||||||
"""Send analytics."""
|
"""Send analytics."""
|
||||||
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
|
|
||||||
return
|
|
||||||
|
|
||||||
hass = self.hass
|
hass = self.hass
|
||||||
supervisor_info = None
|
supervisor_info = None
|
||||||
operating_system_info: dict[str, Any] = {}
|
operating_system_info: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
|
||||||
|
LOGGER.debug("Nothing to submit")
|
||||||
|
return
|
||||||
|
|
||||||
if self._data.uuid is None:
|
if self._data.uuid is None:
|
||||||
self._data.uuid = gen_uuid()
|
self._data.uuid = gen_uuid()
|
||||||
await self._save()
|
await self._store.async_save(dataclass_asdict(self._data))
|
||||||
|
|
||||||
if self.supervisor:
|
if self.supervisor:
|
||||||
supervisor_info = hassio.get_supervisor_info(hass)
|
supervisor_info = hassio.get_supervisor_info(hass)
|
||||||
@@ -463,7 +436,7 @@ class Analytics:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with timeout(30):
|
async with timeout(30):
|
||||||
response = await self.session.post(self.endpoint_basic, json=payload)
|
response = await self.session.post(self.endpoint, json=payload)
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
(
|
(
|
||||||
@@ -476,7 +449,7 @@ class Analytics:
|
|||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Sending analytics failed with statuscode %s from %s",
|
"Sending analytics failed with statuscode %s from %s",
|
||||||
response.status,
|
response.status,
|
||||||
self.endpoint_basic,
|
self.endpoint,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
|
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
|
||||||
@@ -516,182 +489,6 @@ class Analytics:
|
|||||||
if entry.source != SOURCE_IGNORE and entry.disabled_by is None
|
if entry.source != SOURCE_IGNORE and entry.disabled_by is None
|
||||||
)
|
)
|
||||||
|
|
||||||
async def send_snapshot(self, _: datetime | None = None) -> None:
|
|
||||||
"""Send a snapshot."""
|
|
||||||
if not self.onboarded or not self.preferences.get(ATTR_SNAPSHOTS, False):
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = await _async_snapshot_payload(self.hass)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": f"home-assistant/{HA_VERSION}",
|
|
||||||
}
|
|
||||||
if self._data.submission_identifier is not None:
|
|
||||||
headers["X-Device-Database-Submission-Identifier"] = (
|
|
||||||
self._data.submission_identifier
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with timeout(30):
|
|
||||||
response = await self.session.post(
|
|
||||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL, json=payload, headers=headers
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status == 200: # OK
|
|
||||||
response_data = await response.json()
|
|
||||||
new_identifier = response_data.get("submission_identifier")
|
|
||||||
|
|
||||||
if (
|
|
||||||
new_identifier is not None
|
|
||||||
and new_identifier != self._data.submission_identifier
|
|
||||||
):
|
|
||||||
self._data.submission_identifier = new_identifier
|
|
||||||
await self._save()
|
|
||||||
|
|
||||||
LOGGER.info(
|
|
||||||
"Submitted snapshot analytics to Home Assistant servers"
|
|
||||||
)
|
|
||||||
|
|
||||||
elif response.status == 400: # Bad Request
|
|
||||||
response_data = await response.json()
|
|
||||||
error_kind = response_data.get("kind", "unknown")
|
|
||||||
error_message = response_data.get("message", "Unknown error")
|
|
||||||
|
|
||||||
if error_kind == "invalid-submission-identifier":
|
|
||||||
# Clear the invalid identifier and retry on next cycle
|
|
||||||
LOGGER.warning(
|
|
||||||
"Invalid submission identifier to %s, clearing: %s",
|
|
||||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
|
||||||
error_message,
|
|
||||||
)
|
|
||||||
self._data.submission_identifier = None
|
|
||||||
await self._save()
|
|
||||||
else:
|
|
||||||
LOGGER.warning(
|
|
||||||
"Malformed snapshot analytics submission (%s) to %s: %s",
|
|
||||||
error_kind,
|
|
||||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
|
||||||
error_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
elif response.status == 503: # Service Unavailable
|
|
||||||
response_text = await response.text()
|
|
||||||
LOGGER.warning(
|
|
||||||
"Snapshot analytics service %s unavailable: %s",
|
|
||||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
|
||||||
response_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
LOGGER.warning(
|
|
||||||
"Unexpected status code %s when submitting snapshot analytics to %s",
|
|
||||||
response.status,
|
|
||||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
|
||||||
)
|
|
||||||
|
|
||||||
except TimeoutError:
|
|
||||||
LOGGER.error(
|
|
||||||
"Timeout sending snapshot analytics to %s",
|
|
||||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
|
||||||
)
|
|
||||||
except aiohttp.ClientError as err:
|
|
||||||
LOGGER.error(
|
|
||||||
"Error sending snapshot analytics to %s: %r",
|
|
||||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_schedule(self) -> None:
|
|
||||||
"""Schedule analytics."""
|
|
||||||
if not self.onboarded:
|
|
||||||
LOGGER.debug("Analytics not scheduled")
|
|
||||||
if self._basic_scheduled is not None:
|
|
||||||
self._basic_scheduled()
|
|
||||||
self._basic_scheduled = None
|
|
||||||
if self._snapshot_scheduled:
|
|
||||||
self._snapshot_scheduled()
|
|
||||||
self._snapshot_scheduled = None
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.preferences.get(ATTR_BASE, False):
|
|
||||||
LOGGER.debug("Basic analytics not scheduled")
|
|
||||||
if self._basic_scheduled is not None:
|
|
||||||
self._basic_scheduled()
|
|
||||||
self._basic_scheduled = None
|
|
||||||
elif self._basic_scheduled is None:
|
|
||||||
# Wait 15 min after started for basic analytics
|
|
||||||
self._basic_scheduled = async_call_later(
|
|
||||||
self.hass,
|
|
||||||
900,
|
|
||||||
HassJob(
|
|
||||||
self._async_schedule_basic,
|
|
||||||
name="basic analytics schedule",
|
|
||||||
cancel_on_shutdown=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.preferences.get(ATTR_SNAPSHOTS, False) or RELEASE_CHANNEL not in (
|
|
||||||
ReleaseChannel.DEV,
|
|
||||||
ReleaseChannel.NIGHTLY,
|
|
||||||
):
|
|
||||||
LOGGER.debug("Snapshot analytics not scheduled")
|
|
||||||
if self._snapshot_scheduled:
|
|
||||||
self._snapshot_scheduled()
|
|
||||||
self._snapshot_scheduled = None
|
|
||||||
elif self._snapshot_scheduled is None:
|
|
||||||
snapshot_submission_time = self._data.snapshot_submission_time
|
|
||||||
|
|
||||||
if snapshot_submission_time is None:
|
|
||||||
# Randomize the submission time within the 24 hours
|
|
||||||
snapshot_submission_time = random.uniform(0, 86400)
|
|
||||||
self._data.snapshot_submission_time = snapshot_submission_time
|
|
||||||
await self._save()
|
|
||||||
LOGGER.debug(
|
|
||||||
"Initialized snapshot submission time to %s",
|
|
||||||
snapshot_submission_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate delay until next submission
|
|
||||||
current_time = time.time()
|
|
||||||
delay = (snapshot_submission_time - current_time) % 86400
|
|
||||||
|
|
||||||
self._snapshot_scheduled = async_call_later(
|
|
||||||
self.hass,
|
|
||||||
delay,
|
|
||||||
HassJob(
|
|
||||||
self._async_schedule_snapshots,
|
|
||||||
name="snapshot analytics schedule",
|
|
||||||
cancel_on_shutdown=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _async_schedule_basic(self, _: datetime | None = None) -> None:
|
|
||||||
"""Schedule basic analytics."""
|
|
||||||
await self.send_analytics()
|
|
||||||
|
|
||||||
# Send basic analytics every day
|
|
||||||
self._basic_scheduled = async_track_time_interval(
|
|
||||||
self.hass,
|
|
||||||
self.send_analytics,
|
|
||||||
INTERVAL,
|
|
||||||
name="basic analytics daily",
|
|
||||||
cancel_on_shutdown=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _async_schedule_snapshots(self, _: datetime | None = None) -> None:
|
|
||||||
"""Schedule snapshot analytics."""
|
|
||||||
await self.send_snapshot()
|
|
||||||
|
|
||||||
# Send snapshot analytics every day
|
|
||||||
self._snapshot_scheduled = async_track_time_interval(
|
|
||||||
self.hass,
|
|
||||||
self.send_snapshot,
|
|
||||||
INTERVAL,
|
|
||||||
name="snapshot analytics daily",
|
|
||||||
cancel_on_shutdown=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
||||||
"""Extract domains from the YAML configuration."""
|
"""Extract domains from the YAML configuration."""
|
||||||
@@ -708,8 +505,8 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
|||||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||||
|
|
||||||
|
|
||||||
async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||||
"""Return detailed information about entities and devices for a snapshot."""
|
"""Return detailed information about entities and devices."""
|
||||||
dev_reg = dr.async_get(hass)
|
dev_reg = dr.async_get(hass)
|
||||||
ent_reg = er.async_get(hass)
|
ent_reg = er.async_get(hass)
|
||||||
|
|
||||||
@@ -914,13 +711,8 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
|||||||
|
|
||||||
entities_info.append(entity_info)
|
entities_info.append(entity_info)
|
||||||
|
|
||||||
return integrations_info
|
|
||||||
|
|
||||||
|
|
||||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
|
||||||
"""Return detailed information about entities and devices for a direct download."""
|
|
||||||
return {
|
return {
|
||||||
"version": f"home-assistant:{SNAPSHOT_VERSION}",
|
"version": "home-assistant:1",
|
||||||
"home_assistant": HA_VERSION,
|
"home_assistant": HA_VERSION,
|
||||||
"integrations": await _async_snapshot_payload(hass),
|
"integrations": integrations_info,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import voluptuous as vol
|
|||||||
|
|
||||||
ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
|
ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
|
||||||
ANALYTICS_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1"
|
ANALYTICS_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1"
|
||||||
SNAPSHOT_VERSION = "1"
|
|
||||||
ANALYTICS_SNAPSHOT_ENDPOINT_URL = f"https://device-database.eco-dev-aws.openhomefoundation.com/api/v1/snapshot/{SNAPSHOT_VERSION}"
|
|
||||||
DOMAIN = "analytics"
|
DOMAIN = "analytics"
|
||||||
INTERVAL = timedelta(days=1)
|
INTERVAL = timedelta(days=1)
|
||||||
STORAGE_KEY = "core.analytics"
|
STORAGE_KEY = "core.analytics"
|
||||||
@@ -40,7 +38,6 @@ ATTR_PREFERENCES = "preferences"
|
|||||||
ATTR_PROTECTED = "protected"
|
ATTR_PROTECTED = "protected"
|
||||||
ATTR_RECORDER = "recorder"
|
ATTR_RECORDER = "recorder"
|
||||||
ATTR_SLUG = "slug"
|
ATTR_SLUG = "slug"
|
||||||
ATTR_SNAPSHOTS = "snapshots"
|
|
||||||
ATTR_STATE_COUNT = "state_count"
|
ATTR_STATE_COUNT = "state_count"
|
||||||
ATTR_STATISTICS = "statistics"
|
ATTR_STATISTICS = "statistics"
|
||||||
ATTR_SUPERVISOR = "supervisor"
|
ATTR_SUPERVISOR = "supervisor"
|
||||||
@@ -54,7 +51,6 @@ ATTR_VERSION = "version"
|
|||||||
PREFERENCE_SCHEMA = vol.Schema(
|
PREFERENCE_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(ATTR_BASE): bool,
|
vol.Optional(ATTR_BASE): bool,
|
||||||
vol.Optional(ATTR_SNAPSHOTS): bool,
|
|
||||||
vol.Optional(ATTR_DIAGNOSTICS): bool,
|
vol.Optional(ATTR_DIAGNOSTICS): bool,
|
||||||
vol.Optional(ATTR_STATISTICS): bool,
|
vol.Optional(ATTR_STATISTICS): bool,
|
||||||
vol.Optional(ATTR_USAGE): bool,
|
vol.Optional(ATTR_USAGE): bool,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
@@ -284,11 +283,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_CHAT_MODEL,
|
CONF_CHAT_MODEL,
|
||||||
default=RECOMMENDED_CHAT_MODEL,
|
default=RECOMMENDED_CHAT_MODEL,
|
||||||
): SelectSelector(
|
): str,
|
||||||
SelectSelectorConfig(
|
|
||||||
options=await self._get_model_list(), custom_value=True
|
|
||||||
)
|
|
||||||
),
|
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_MAX_TOKENS,
|
CONF_MAX_TOKENS,
|
||||||
default=RECOMMENDED_MAX_TOKENS,
|
default=RECOMMENDED_MAX_TOKENS,
|
||||||
@@ -399,39 +394,6 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
last_step=True,
|
last_step=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _get_model_list(self) -> list[SelectOptionDict]:
|
|
||||||
"""Get list of available models."""
|
|
||||||
try:
|
|
||||||
client = await self.hass.async_add_executor_job(
|
|
||||||
partial(
|
|
||||||
anthropic.AsyncAnthropic,
|
|
||||||
api_key=self._get_entry().data[CONF_API_KEY],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
models = (await client.models.list()).data
|
|
||||||
except anthropic.AnthropicError:
|
|
||||||
models = []
|
|
||||||
_LOGGER.debug("Available models: %s", models)
|
|
||||||
model_options: list[SelectOptionDict] = []
|
|
||||||
short_form = re.compile(r"[^\d]-\d$")
|
|
||||||
for model_info in models:
|
|
||||||
# Resolve alias from versioned model name:
|
|
||||||
model_alias = (
|
|
||||||
model_info.id[:-9]
|
|
||||||
if model_info.id
|
|
||||||
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
|
|
||||||
else model_info.id
|
|
||||||
)
|
|
||||||
if short_form.search(model_alias):
|
|
||||||
model_alias += "-0"
|
|
||||||
model_options.append(
|
|
||||||
SelectOptionDict(
|
|
||||||
label=model_info.display_name,
|
|
||||||
value=model_alias,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return model_options
|
|
||||||
|
|
||||||
async def _get_location_data(self) -> dict[str, str]:
|
async def _get_location_data(self) -> dict[str, str]:
|
||||||
"""Get approximate location data of the user."""
|
"""Get approximate location data of the user."""
|
||||||
location_data: dict[str, str] = {}
|
location_data: dict[str, str] = {}
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
|||||||
type="tool_use",
|
type="tool_use",
|
||||||
id=response.content_block.id,
|
id=response.content_block.id,
|
||||||
name=response.content_block.name,
|
name=response.content_block.name,
|
||||||
input={},
|
input="",
|
||||||
)
|
)
|
||||||
current_tool_args = ""
|
current_tool_args = ""
|
||||||
if response.content_block.name == output_tool:
|
if response.content_block.name == output_tool:
|
||||||
@@ -459,7 +459,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
|||||||
type="server_tool_use",
|
type="server_tool_use",
|
||||||
id=response.content_block.id,
|
id=response.content_block.id,
|
||||||
name=response.content_block.name,
|
name=response.content_block.name,
|
||||||
input={},
|
input="",
|
||||||
)
|
)
|
||||||
current_tool_args = ""
|
current_tool_args = ""
|
||||||
elif isinstance(response.content_block, WebSearchToolResultBlock):
|
elif isinstance(response.content_block, WebSearchToolResultBlock):
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["anthropic==0.73.0"]
|
"requirements": ["anthropic==0.69.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,26 +7,3 @@ CONNECTION_TIMEOUT: int = 10
|
|||||||
|
|
||||||
# Field name of last self test retrieved from apcupsd.
|
# Field name of last self test retrieved from apcupsd.
|
||||||
LAST_S_TEST: Final = "laststest"
|
LAST_S_TEST: Final = "laststest"
|
||||||
|
|
||||||
# Mapping of deprecated sensor keys (as reported by apcupsd, lower-cased) to their deprecation
|
|
||||||
# repair issue translation keys.
|
|
||||||
DEPRECATED_SENSORS: Final = {
|
|
||||||
"apc": "apc_deprecated",
|
|
||||||
"end apc": "date_deprecated",
|
|
||||||
"date": "date_deprecated",
|
|
||||||
"apcmodel": "available_via_device_info",
|
|
||||||
"model": "available_via_device_info",
|
|
||||||
"firmware": "available_via_device_info",
|
|
||||||
"version": "available_via_device_info",
|
|
||||||
"upsname": "available_via_device_info",
|
|
||||||
"serialno": "available_via_device_info",
|
|
||||||
}
|
|
||||||
|
|
||||||
AVAILABLE_VIA_DEVICE_ATTR: Final = {
|
|
||||||
"apcmodel": "model",
|
|
||||||
"model": "model",
|
|
||||||
"firmware": "hw_version",
|
|
||||||
"version": "sw_version",
|
|
||||||
"upsname": "name",
|
|
||||||
"serialno": "serial_number",
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from homeassistant.components.automation import automations_with_entity
|
|
||||||
from homeassistant.components.script import scripts_with_entity
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
SensorEntity,
|
SensorEntity,
|
||||||
@@ -24,11 +22,9 @@ from homeassistant.const import (
|
|||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import entity_registry as er
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
import homeassistant.helpers.issue_registry as ir
|
|
||||||
|
|
||||||
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
|
from .const import LAST_S_TEST
|
||||||
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
|
||||||
from .entity import APCUPSdEntity
|
from .entity import APCUPSdEntity
|
||||||
|
|
||||||
@@ -532,62 +528,3 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
|||||||
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
|
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
|
||||||
if not self.native_unit_of_measurement:
|
if not self.native_unit_of_measurement:
|
||||||
self._attr_native_unit_of_measurement = inferred_unit
|
self._attr_native_unit_of_measurement = inferred_unit
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
|
||||||
"""Handle when entity is added to Home Assistant.
|
|
||||||
|
|
||||||
If this is a deprecated sensor entity, create a repair issue to guide
|
|
||||||
the user to disable it.
|
|
||||||
"""
|
|
||||||
await super().async_added_to_hass()
|
|
||||||
|
|
||||||
if not self.enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
|
|
||||||
if not reason:
|
|
||||||
return
|
|
||||||
|
|
||||||
automations = automations_with_entity(self.hass, self.entity_id)
|
|
||||||
scripts = scripts_with_entity(self.hass, self.entity_id)
|
|
||||||
if not automations and not scripts:
|
|
||||||
return
|
|
||||||
|
|
||||||
entity_registry = er.async_get(self.hass)
|
|
||||||
items = [
|
|
||||||
f"- [{entry.name or entry.original_name or entity_id}]"
|
|
||||||
f"(/config/{integration}/edit/{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
|
||||||
for integration, entities in (
|
|
||||||
("automation", automations),
|
|
||||||
("script", scripts),
|
|
||||||
)
|
|
||||||
for entity_id in entities
|
|
||||||
if (entry := entity_registry.async_get(entity_id))
|
|
||||||
]
|
|
||||||
placeholders = {
|
|
||||||
"entity_name": str(self.name or self.entity_id),
|
|
||||||
"entity_id": self.entity_id,
|
|
||||||
"items": "\n".join(items),
|
|
||||||
}
|
|
||||||
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
|
|
||||||
placeholders["available_via_device_attr"] = via_attr
|
|
||||||
if device_entry := self.device_entry:
|
|
||||||
placeholders["device_id"] = device_entry.id
|
|
||||||
|
|
||||||
ir.async_create_issue(
|
|
||||||
self.hass,
|
|
||||||
DOMAIN,
|
|
||||||
f"{reason}_{self.entity_id}",
|
|
||||||
breaks_in_ha_version="2026.6.0",
|
|
||||||
is_fixable=False,
|
|
||||||
severity=ir.IssueSeverity.WARNING,
|
|
||||||
translation_key=reason,
|
|
||||||
translation_placeholders=placeholders,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
|
||||||
"""Handle when entity will be removed from Home Assistant."""
|
|
||||||
await super().async_will_remove_from_hass()
|
|
||||||
|
|
||||||
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
|
|
||||||
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")
|
|
||||||
|
|||||||
@@ -241,19 +241,5 @@
|
|||||||
"cannot_connect": {
|
"cannot_connect": {
|
||||||
"message": "Cannot connect to APC UPS Daemon."
|
"message": "Cannot connect to APC UPS Daemon."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"issues": {
|
|
||||||
"apc_deprecated": {
|
|
||||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
|
||||||
"title": "{entity_name} sensor is deprecated"
|
|
||||||
},
|
|
||||||
"available_via_device_info": {
|
|
||||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
|
||||||
"title": "{entity_name} sensor is deprecated"
|
|
||||||
},
|
|
||||||
"date_deprecated": {
|
|
||||||
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
|
|
||||||
"title": "{entity_name} sensor is deprecated"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge](
|
|||||||
|
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
return dict(zip(keys, list(data.values()), strict=False))
|
return dict(zip(keys, list(data.values()), strict=False))
|
||||||
|
if not isinstance(data, (list, tuple)):
|
||||||
|
raise UpdateFailed("Received invalid data type")
|
||||||
return dict(zip(keys, data, strict=False))
|
return dict(zip(keys, data, strict=False))
|
||||||
|
|
||||||
return _wrapper
|
return _wrapper
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/awair",
|
"documentation": "https://www.home-assistant.io/integrations/awair",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["python_awair"],
|
"loggers": ["python_awair"],
|
||||||
"requirements": ["python-awair==0.2.5"],
|
"requirements": ["python-awair==0.2.4"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"name": "awair*",
|
"name": "awair*",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"bluetooth-adapters==2.1.0",
|
"bluetooth-adapters==2.1.0",
|
||||||
"bluetooth-auto-recovery==1.5.3",
|
"bluetooth-auto-recovery==1.5.3",
|
||||||
"bluetooth-data-tools==1.28.4",
|
"bluetooth-data-tools==1.28.4",
|
||||||
"dbus-fast==3.0.0",
|
"dbus-fast==2.45.0",
|
||||||
"habluetooth==5.7.0"
|
"habluetooth==5.7.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class BrotherPrinterEntity(CoordinatorEntity[BrotherDataUpdateCoordinator]):
|
|||||||
connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)},
|
connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)},
|
||||||
serial_number=coordinator.brother.serial,
|
serial_number=coordinator.brother.serial,
|
||||||
manufacturer="Brother",
|
manufacturer="Brother",
|
||||||
model_id=coordinator.brother.model,
|
model=coordinator.brother.model,
|
||||||
name=coordinator.brother.model,
|
name=coordinator.brother.model,
|
||||||
sw_version=coordinator.brother.firmware,
|
sw_version=coordinator.brother.firmware,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
|
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
|
||||||
"quality_scale": "platinum",
|
|
||||||
"requirements": ["brother==5.1.1"],
|
"requirements": ["brother==5.1.1"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
rules:
|
|
||||||
# Bronze
|
|
||||||
action-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: The integration does not register services.
|
|
||||||
appropriate-polling: done
|
|
||||||
brands: done
|
|
||||||
common-modules: done
|
|
||||||
config-flow-test-coverage: done
|
|
||||||
config-flow: done
|
|
||||||
dependency-transparency: done
|
|
||||||
docs-actions:
|
|
||||||
status: exempt
|
|
||||||
comment: The integration does not register services.
|
|
||||||
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:
|
|
||||||
status: exempt
|
|
||||||
comment: The integration does not register services.
|
|
||||||
config-entry-unloading: done
|
|
||||||
docs-configuration-parameters:
|
|
||||||
status: exempt
|
|
||||||
comment: No options to configure.
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable: done
|
|
||||||
integration-owner: done
|
|
||||||
log-when-unavailable: done
|
|
||||||
parallel-updates: done
|
|
||||||
reauthentication-flow:
|
|
||||||
status: exempt
|
|
||||||
comment: SNMP doesn't return error identifying an authentication problem, to change the SNMP community (simple password) the user should use reconfigure flow.
|
|
||||||
test-coverage: done
|
|
||||||
|
|
||||||
# Gold
|
|
||||||
devices: done
|
|
||||||
diagnostics: done
|
|
||||||
discovery-update-info: done
|
|
||||||
discovery: done
|
|
||||||
docs-data-update: done
|
|
||||||
docs-examples: done
|
|
||||||
docs-known-limitations: done
|
|
||||||
docs-supported-devices: done
|
|
||||||
docs-supported-functions: done
|
|
||||||
docs-troubleshooting: done
|
|
||||||
docs-use-cases: done
|
|
||||||
dynamic-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: This integration has a fixed single device.
|
|
||||||
entity-category: done
|
|
||||||
entity-device-class: done
|
|
||||||
entity-disabled-by-default: done
|
|
||||||
entity-translations: done
|
|
||||||
exception-translations: done
|
|
||||||
icon-translations: done
|
|
||||||
reconfiguration-flow: done
|
|
||||||
repair-issues:
|
|
||||||
status: exempt
|
|
||||||
comment: This integration doesn't have any cases where raising an issue is needed.
|
|
||||||
stale-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: This integration has a fixed single device.
|
|
||||||
|
|
||||||
# Platinum
|
|
||||||
async-dependency: done
|
|
||||||
inject-websession:
|
|
||||||
status: exempt
|
|
||||||
comment: The integration does not connect via HTTP instead it uses a shared SNMP engine.
|
|
||||||
strict-typing: done
|
|
||||||
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
|
|||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.typing import StateType
|
from homeassistant.helpers.typing import StateType
|
||||||
@@ -345,10 +345,12 @@ class BrotherPrinterSensor(BrotherPrinterEntity, SensorEntity):
|
|||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
super().__init__(coordinator)
|
super().__init__(coordinator)
|
||||||
|
|
||||||
|
self._attr_native_value = description.value(coordinator.data)
|
||||||
self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}"
|
self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}"
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
|
|
||||||
@property
|
@callback
|
||||||
def native_value(self) -> StateType | datetime:
|
def _handle_coordinator_update(self) -> None:
|
||||||
"""Return the native value of the sensor."""
|
"""Handle updated data from the coordinator."""
|
||||||
return self.entity_description.value(self.coordinator.data)
|
self._attr_native_value = self.entity_description.value(self.coordinator.data)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|||||||
@@ -74,11 +74,8 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
|||||||
super().__init__(data.fast_coordinator, data)
|
super().__init__(data.fast_coordinator, data)
|
||||||
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
|
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
|
||||||
|
|
||||||
# Set temperature range if available, otherwise use Home Assistant defaults
|
self._attr_min_temp = data.static.min_temp.value
|
||||||
if data.static.min_temp is not None and data.static.min_temp.value is not None:
|
self._attr_max_temp = data.static.max_temp.value
|
||||||
self._attr_min_temp = data.static.min_temp.value
|
|
||||||
if data.static.max_temp is not None and data.static.max_temp.value is not None:
|
|
||||||
self._attr_max_temp = data.static.max_temp.value
|
|
||||||
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["bsblan"],
|
"loggers": ["bsblan"],
|
||||||
"requirements": ["python-bsblan==3.1.1"],
|
"requirements": ["python-bsblan==3.1.0"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"name": "bsb-lan*",
|
"name": "bsb-lan*",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from collections.abc import Awaitable, Callable
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from typing import cast
|
||||||
|
|
||||||
from hass_nabucasa import Cloud
|
from hass_nabucasa import Cloud
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -55,7 +55,6 @@ from .const import (
|
|||||||
CONF_ALIASES,
|
CONF_ALIASES,
|
||||||
CONF_API_SERVER,
|
CONF_API_SERVER,
|
||||||
CONF_COGNITO_CLIENT_ID,
|
CONF_COGNITO_CLIENT_ID,
|
||||||
CONF_DISCOVERY_SERVICE_ACTIONS,
|
|
||||||
CONF_ENTITY_CONFIG,
|
CONF_ENTITY_CONFIG,
|
||||||
CONF_FILTER,
|
CONF_FILTER,
|
||||||
CONF_GOOGLE_ACTIONS,
|
CONF_GOOGLE_ACTIONS,
|
||||||
@@ -86,10 +85,6 @@ SIGNAL_CLOUD_CONNECTION_STATE: SignalType[CloudConnectionState] = SignalType(
|
|||||||
"CLOUD_CONNECTION_STATE"
|
"CLOUD_CONNECTION_STATE"
|
||||||
)
|
)
|
||||||
|
|
||||||
_SIGNAL_CLOUDHOOKS_UPDATED: SignalType[dict[str, Any]] = SignalType(
|
|
||||||
"CLOUDHOOKS_UPDATED"
|
|
||||||
)
|
|
||||||
|
|
||||||
STARTUP_REPAIR_DELAY = 1 # 1 hour
|
STARTUP_REPAIR_DELAY = 1 # 1 hour
|
||||||
|
|
||||||
ALEXA_ENTITY_SCHEMA = vol.Schema(
|
ALEXA_ENTITY_SCHEMA = vol.Schema(
|
||||||
@@ -144,7 +139,6 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
{
|
{
|
||||||
vol.Required(CONF_MODE): vol.In([MODE_DEV]),
|
vol.Required(CONF_MODE): vol.In([MODE_DEV]),
|
||||||
vol.Required(CONF_API_SERVER): str,
|
vol.Required(CONF_API_SERVER): str,
|
||||||
vol.Optional(CONF_DISCOVERY_SERVICE_ACTIONS): {str: cv.url},
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
_BASE_CONFIG_SCHEMA.extend(
|
_BASE_CONFIG_SCHEMA.extend(
|
||||||
@@ -246,24 +240,6 @@ async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None:
|
|||||||
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
|
await hass.data[DATA_CLOUD].cloudhooks.async_delete(webhook_id)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_listen_cloudhook_change(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
webhook_id: str,
|
|
||||||
on_change: Callable[[dict[str, Any] | None], None],
|
|
||||||
) -> Callable[[], None]:
|
|
||||||
"""Listen for cloudhook changes for the given webhook and notify when modified or deleted."""
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _handle_cloudhooks_updated(cloudhooks: dict[str, Any]) -> None:
|
|
||||||
"""Handle cloudhooks updated signal."""
|
|
||||||
on_change(cloudhooks.get(webhook_id))
|
|
||||||
|
|
||||||
return async_dispatcher_connect(
|
|
||||||
hass, _SIGNAL_CLOUDHOOKS_UPDATED, _handle_cloudhooks_updated
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bind_hass
|
@bind_hass
|
||||||
@callback
|
@callback
|
||||||
def async_remote_ui_url(hass: HomeAssistant) -> str:
|
def async_remote_ui_url(hass: HomeAssistant) -> str:
|
||||||
@@ -311,7 +287,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
|
||||||
|
|
||||||
_handle_prefs_updated(hass, cloud)
|
_remote_handle_prefs_updated(cloud)
|
||||||
_setup_services(hass, prefs)
|
_setup_services(hass, prefs)
|
||||||
|
|
||||||
async def async_startup_repairs(_: datetime) -> None:
|
async def async_startup_repairs(_: datetime) -> None:
|
||||||
@@ -395,32 +371,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> None:
|
def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None:
|
||||||
"""Register handler for cloud preferences updates."""
|
"""Handle remote preferences updated."""
|
||||||
cur_remote_enabled = cloud.client.prefs.remote_enabled
|
cur_pref = cloud.client.prefs.remote_enabled
|
||||||
cur_cloudhooks = cloud.client.prefs.cloudhooks
|
|
||||||
lock = asyncio.Lock()
|
lock = asyncio.Lock()
|
||||||
|
|
||||||
async def on_prefs_updated(prefs: CloudPreferences) -> None:
|
# Sync remote connection with prefs
|
||||||
"""Handle cloud preferences updates."""
|
async def remote_prefs_updated(prefs: CloudPreferences) -> None:
|
||||||
nonlocal cur_remote_enabled
|
"""Update remote status."""
|
||||||
nonlocal cur_cloudhooks
|
nonlocal cur_pref
|
||||||
|
|
||||||
# Lock protects cur_ state variables from concurrent updates
|
|
||||||
async with lock:
|
async with lock:
|
||||||
if cur_cloudhooks != prefs.cloudhooks:
|
if prefs.remote_enabled == cur_pref:
|
||||||
cur_cloudhooks = prefs.cloudhooks
|
|
||||||
async_dispatcher_send(hass, _SIGNAL_CLOUDHOOKS_UPDATED, cur_cloudhooks)
|
|
||||||
|
|
||||||
if prefs.remote_enabled == cur_remote_enabled:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if cur_remote_enabled := prefs.remote_enabled:
|
if cur_pref := prefs.remote_enabled:
|
||||||
await cloud.remote.connect()
|
await cloud.remote.connect()
|
||||||
else:
|
else:
|
||||||
await cloud.remote.disconnect()
|
await cloud.remote.disconnect()
|
||||||
|
|
||||||
cloud.client.prefs.async_listen_updates(on_prefs_updated)
|
cloud.client.prefs.async_listen_updates(remote_prefs_updated)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ CONF_ACCOUNT_LINK_SERVER = "account_link_server"
|
|||||||
CONF_ACCOUNTS_SERVER = "accounts_server"
|
CONF_ACCOUNTS_SERVER = "accounts_server"
|
||||||
CONF_ACME_SERVER = "acme_server"
|
CONF_ACME_SERVER = "acme_server"
|
||||||
CONF_API_SERVER = "api_server"
|
CONF_API_SERVER = "api_server"
|
||||||
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"
|
|
||||||
CONF_RELAYER_SERVER = "relayer_server"
|
CONF_RELAYER_SERVER = "relayer_server"
|
||||||
CONF_REMOTESTATE_SERVER = "remotestate_server"
|
CONF_REMOTESTATE_SERVER = "remotestate_server"
|
||||||
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"
|
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ def async_setup(hass: HomeAssistant) -> bool:
|
|||||||
websocket_api.async_register_command(hass, websocket_create_area)
|
websocket_api.async_register_command(hass, websocket_create_area)
|
||||||
websocket_api.async_register_command(hass, websocket_delete_area)
|
websocket_api.async_register_command(hass, websocket_delete_area)
|
||||||
websocket_api.async_register_command(hass, websocket_update_area)
|
websocket_api.async_register_command(hass, websocket_update_area)
|
||||||
websocket_api.async_register_command(hass, websocket_reorder_areas)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -146,27 +145,3 @@ def websocket_update_area(
|
|||||||
connection.send_error(msg["id"], "invalid_info", str(err))
|
connection.send_error(msg["id"], "invalid_info", str(err))
|
||||||
else:
|
else:
|
||||||
connection.send_result(msg["id"], entry.json_fragment)
|
connection.send_result(msg["id"], entry.json_fragment)
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{
|
|
||||||
vol.Required("type"): "config/area_registry/reorder",
|
|
||||||
vol.Required("area_ids"): [str],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@websocket_api.require_admin
|
|
||||||
@callback
|
|
||||||
def websocket_reorder_areas(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: websocket_api.ActiveConnection,
|
|
||||||
msg: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Handle reorder areas websocket command."""
|
|
||||||
registry = ar.async_get(hass)
|
|
||||||
|
|
||||||
try:
|
|
||||||
registry.async_reorder(msg["area_ids"])
|
|
||||||
except ValueError as err:
|
|
||||||
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
|
|
||||||
else:
|
|
||||||
connection.send_result(msg["id"])
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ def async_setup(hass: HomeAssistant) -> bool:
|
|||||||
websocket_api.async_register_command(hass, websocket_create_floor)
|
websocket_api.async_register_command(hass, websocket_create_floor)
|
||||||
websocket_api.async_register_command(hass, websocket_delete_floor)
|
websocket_api.async_register_command(hass, websocket_delete_floor)
|
||||||
websocket_api.async_register_command(hass, websocket_update_floor)
|
websocket_api.async_register_command(hass, websocket_update_floor)
|
||||||
websocket_api.async_register_command(hass, websocket_reorder_floors)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -128,28 +127,6 @@ def websocket_update_floor(
|
|||||||
connection.send_result(msg["id"], _entry_dict(entry))
|
connection.send_result(msg["id"], _entry_dict(entry))
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{
|
|
||||||
vol.Required("type"): "config/floor_registry/reorder",
|
|
||||||
vol.Required("floor_ids"): [str],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@websocket_api.require_admin
|
|
||||||
@callback
|
|
||||||
def websocket_reorder_floors(
|
|
||||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Handle reorder floors websocket command."""
|
|
||||||
registry = fr.async_get(hass)
|
|
||||||
|
|
||||||
try:
|
|
||||||
registry.async_reorder(msg["floor_ids"])
|
|
||||||
except ValueError as err:
|
|
||||||
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
|
|
||||||
else:
|
|
||||||
connection.send_result(msg["id"])
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _entry_dict(entry: FloorEntry) -> dict[str, Any]:
|
def _entry_dict(entry: FloorEntry) -> dict[str, Any]:
|
||||||
"""Convert entry to API format."""
|
"""Convert entry to API format."""
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Virtual integration: Cosori."""
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "cosori",
|
|
||||||
"name": "Cosori",
|
|
||||||
"integration_type": "virtual",
|
|
||||||
"supported_by": "vesync"
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.util.ssl import get_default_context
|
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_AUTHORIZE_STRING,
|
CONF_AUTHORIZE_STRING,
|
||||||
@@ -32,13 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool
|
|||||||
expires_at=entry.data[CONF_EXPIRES_AT],
|
expires_at=entry.data[CONF_EXPIRES_AT],
|
||||||
)
|
)
|
||||||
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
|
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
|
||||||
ssl_context = get_default_context()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cync = await Cync.create(
|
cync = await Cync.create(cync_auth)
|
||||||
auth=cync_auth,
|
|
||||||
ssl_context=ssl_context,
|
|
||||||
)
|
|
||||||
except AuthFailedError as ex:
|
except AuthFailedError as ex:
|
||||||
raise ConfigEntryAuthFailed("User token invalid") from ex
|
raise ConfigEntryAuthFailed("User token invalid") from ex
|
||||||
except CyncError as ex:
|
except CyncError as ex:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
from homeassistant.util import Throttle
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -169,7 +167,6 @@ class DecoraWifiLight(LightEntity):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
_LOGGER.error("Failed to turn off myLeviton switch")
|
_LOGGER.error("Failed to turn off myLeviton switch")
|
||||||
|
|
||||||
@Throttle(timedelta(seconds=30))
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
"""Fetch new state data for this switch."""
|
"""Fetch new state data for this switch."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,10 +5,5 @@
|
|||||||
"default": "mdi:chart-line"
|
"default": "mdi:chart-line"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"reload": {
|
|
||||||
"service": "mdi:reload"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ from homeassistant.const import (
|
|||||||
ATTR_UNIT_OF_MEASUREMENT,
|
ATTR_UNIT_OF_MEASUREMENT,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
CONF_SOURCE,
|
CONF_SOURCE,
|
||||||
CONF_UNIQUE_ID,
|
|
||||||
STATE_UNAVAILABLE,
|
STATE_UNAVAILABLE,
|
||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
Platform,
|
|
||||||
UnitOfTime,
|
UnitOfTime,
|
||||||
)
|
)
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
@@ -46,7 +44,6 @@ from homeassistant.helpers.event import (
|
|||||||
async_track_state_change_event,
|
async_track_state_change_event,
|
||||||
async_track_state_report_event,
|
async_track_state_report_event,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.reload import async_setup_reload_service
|
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@@ -56,7 +53,6 @@ from .const import (
|
|||||||
CONF_UNIT,
|
CONF_UNIT,
|
||||||
CONF_UNIT_PREFIX,
|
CONF_UNIT_PREFIX,
|
||||||
CONF_UNIT_TIME,
|
CONF_UNIT_TIME,
|
||||||
DOMAIN,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -89,7 +85,6 @@ DEFAULT_TIME_WINDOW = 0
|
|||||||
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_NAME): cv.string,
|
vol.Optional(CONF_NAME): cv.string,
|
||||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
|
||||||
vol.Required(CONF_SOURCE): cv.entity_id,
|
vol.Required(CONF_SOURCE): cv.entity_id,
|
||||||
vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int),
|
vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int),
|
||||||
vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES),
|
vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES),
|
||||||
@@ -150,8 +145,6 @@ async def async_setup_platform(
|
|||||||
discovery_info: DiscoveryInfoType | None = None,
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the derivative sensor."""
|
"""Set up the derivative sensor."""
|
||||||
await async_setup_reload_service(hass, DOMAIN, [Platform.SENSOR])
|
|
||||||
|
|
||||||
derivative = DerivativeSensor(
|
derivative = DerivativeSensor(
|
||||||
hass,
|
hass,
|
||||||
name=config.get(CONF_NAME),
|
name=config.get(CONF_NAME),
|
||||||
@@ -161,7 +154,7 @@ async def async_setup_platform(
|
|||||||
unit_of_measurement=config.get(CONF_UNIT),
|
unit_of_measurement=config.get(CONF_UNIT),
|
||||||
unit_prefix=config[CONF_UNIT_PREFIX],
|
unit_prefix=config[CONF_UNIT_PREFIX],
|
||||||
unit_time=config[CONF_UNIT_TIME],
|
unit_time=config[CONF_UNIT_TIME],
|
||||||
unique_id=config.get(CONF_UNIQUE_ID),
|
unique_id=None,
|
||||||
max_sub_interval=config.get(CONF_MAX_SUB_INTERVAL),
|
max_sub_interval=config.get(CONF_MAX_SUB_INTERVAL),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -293,14 +286,14 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
)
|
)
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
async def _handle_restore(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Handle entity which will be added."""
|
||||||
|
await super().async_added_to_hass()
|
||||||
restored_data = await self.async_get_last_sensor_data()
|
restored_data = await self.async_get_last_sensor_data()
|
||||||
if restored_data:
|
if restored_data:
|
||||||
if self._attr_native_unit_of_measurement is None:
|
self._attr_native_unit_of_measurement = (
|
||||||
# Only restore the unit if it's not assigned from YAML
|
restored_data.native_unit_of_measurement
|
||||||
self._attr_native_unit_of_measurement = (
|
)
|
||||||
restored_data.native_unit_of_measurement
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
self._attr_native_value = round(
|
self._attr_native_value = round(
|
||||||
Decimal(restored_data.native_value), # type: ignore[arg-type]
|
Decimal(restored_data.native_value), # type: ignore[arg-type]
|
||||||
@@ -309,11 +302,6 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
except (InvalidOperation, TypeError):
|
except (InvalidOperation, TypeError):
|
||||||
self._attr_native_value = None
|
self._attr_native_value = None
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
|
||||||
"""Handle entity which will be added."""
|
|
||||||
await super().async_added_to_hass()
|
|
||||||
await self._handle_restore()
|
|
||||||
|
|
||||||
source_state = self.hass.states.get(self._sensor_source_id)
|
source_state = self.hass.states.get(self._sensor_source_id)
|
||||||
self._derive_and_set_attributes_from_state(source_state)
|
self._derive_and_set_attributes_from_state(source_state)
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
reload:
|
|
||||||
@@ -58,11 +58,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
|
||||||
"reload": {
|
|
||||||
"description": "Reloads derivative sensors from the YAML-configuration.",
|
|
||||||
"name": "[%key:common::action::reload%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"title": "Derivative sensor"
|
"title": "Derivative sensor"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["async_upnp_client"],
|
"loggers": ["async_upnp_client"],
|
||||||
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
|
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"dependencies": ["ssdp"],
|
"dependencies": ["ssdp"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["async-upnp-client==0.46.0"],
|
"requirements": ["async-upnp-client==0.45.0"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||||
|
|||||||
259
homeassistant/components/dominos/__init__.py
Normal file
259
homeassistant/components/dominos/__init__.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""Support for Dominos Pizza ordering."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pizzapi import Address, Customer, Order
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components import http
|
||||||
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.entity import Entity
|
||||||
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
from homeassistant.util import Throttle
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The domain of your component. Should be equal to the name of your component.
|
||||||
|
DOMAIN = "dominos"
|
||||||
|
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||||
|
|
||||||
|
ATTR_COUNTRY = "country_code"
|
||||||
|
ATTR_FIRST_NAME = "first_name"
|
||||||
|
ATTR_LAST_NAME = "last_name"
|
||||||
|
ATTR_EMAIL = "email"
|
||||||
|
ATTR_PHONE = "phone"
|
||||||
|
ATTR_ADDRESS = "address"
|
||||||
|
ATTR_ORDERS = "orders"
|
||||||
|
ATTR_SHOW_MENU = "show_menu"
|
||||||
|
ATTR_ORDER_ENTITY = "order_entity_id"
|
||||||
|
ATTR_ORDER_NAME = "name"
|
||||||
|
ATTR_ORDER_CODES = "codes"
|
||||||
|
|
||||||
|
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
|
||||||
|
MIN_TIME_BETWEEN_STORE_UPDATES = timedelta(minutes=3330)
|
||||||
|
|
||||||
|
_ORDERS_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_ORDER_NAME): cv.string,
|
||||||
|
vol.Required(ATTR_ORDER_CODES): vol.All(cv.ensure_list, [cv.string]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
DOMAIN: vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_COUNTRY): cv.string,
|
||||||
|
vol.Required(ATTR_FIRST_NAME): cv.string,
|
||||||
|
vol.Required(ATTR_LAST_NAME): cv.string,
|
||||||
|
vol.Required(ATTR_EMAIL): cv.string,
|
||||||
|
vol.Required(ATTR_PHONE): cv.string,
|
||||||
|
vol.Required(ATTR_ADDRESS): cv.string,
|
||||||
|
vol.Optional(ATTR_SHOW_MENU): cv.boolean,
|
||||||
|
vol.Optional(ATTR_ORDERS, default=[]): vol.All(
|
||||||
|
cv.ensure_list, [_ORDERS_SCHEMA]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
extra=vol.ALLOW_EXTRA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
"""Set up is called when Home Assistant is loading our component."""
|
||||||
|
dominos = Dominos(hass, config)
|
||||||
|
|
||||||
|
component = EntityComponent[DominosOrder](_LOGGER, DOMAIN, hass)
|
||||||
|
hass.data[DOMAIN] = {}
|
||||||
|
entities: list[DominosOrder] = []
|
||||||
|
conf = config[DOMAIN]
|
||||||
|
|
||||||
|
hass.services.register(
|
||||||
|
DOMAIN,
|
||||||
|
"order",
|
||||||
|
dominos.handle_order,
|
||||||
|
vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_ORDER_ENTITY): cv.entity_ids,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if conf.get(ATTR_SHOW_MENU):
|
||||||
|
hass.http.register_view(DominosProductListView(dominos))
|
||||||
|
|
||||||
|
for order_info in conf.get(ATTR_ORDERS):
|
||||||
|
order = DominosOrder(order_info, dominos)
|
||||||
|
entities.append(order)
|
||||||
|
|
||||||
|
component.add_entities(entities)
|
||||||
|
|
||||||
|
# Return boolean to indicate that initialization was successfully.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class Dominos:
|
||||||
|
"""Main Dominos service."""
|
||||||
|
|
||||||
|
def __init__(self, hass, config):
|
||||||
|
"""Set up main service."""
|
||||||
|
conf = config[DOMAIN]
|
||||||
|
|
||||||
|
self.hass = hass
|
||||||
|
self.customer = Customer(
|
||||||
|
conf.get(ATTR_FIRST_NAME),
|
||||||
|
conf.get(ATTR_LAST_NAME),
|
||||||
|
conf.get(ATTR_EMAIL),
|
||||||
|
conf.get(ATTR_PHONE),
|
||||||
|
conf.get(ATTR_ADDRESS),
|
||||||
|
)
|
||||||
|
self.address = Address(
|
||||||
|
*self.customer.address.split(","), country=conf.get(ATTR_COUNTRY)
|
||||||
|
)
|
||||||
|
self.country = conf.get(ATTR_COUNTRY)
|
||||||
|
try:
|
||||||
|
self.closest_store = self.address.closest_store()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
self.closest_store = None
|
||||||
|
|
||||||
|
def handle_order(self, call: ServiceCall) -> None:
|
||||||
|
"""Handle ordering pizza."""
|
||||||
|
entity_ids = call.data[ATTR_ORDER_ENTITY]
|
||||||
|
|
||||||
|
target_orders = [
|
||||||
|
order
|
||||||
|
for order in self.hass.data[DOMAIN]["entities"]
|
||||||
|
if order.entity_id in entity_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
for order in target_orders:
|
||||||
|
order.place()
|
||||||
|
|
||||||
|
@Throttle(MIN_TIME_BETWEEN_STORE_UPDATES)
|
||||||
|
def update_closest_store(self):
|
||||||
|
"""Update the shared closest store (if open)."""
|
||||||
|
try:
|
||||||
|
self.closest_store = self.address.closest_store()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
self.closest_store = None
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_menu(self):
|
||||||
|
"""Return the products from the closest stores menu."""
|
||||||
|
self.update_closest_store()
|
||||||
|
if self.closest_store is None:
|
||||||
|
_LOGGER.warning("Cannot get menu. Store may be closed")
|
||||||
|
return []
|
||||||
|
menu = self.closest_store.get_menu()
|
||||||
|
product_entries = []
|
||||||
|
|
||||||
|
for product in menu.products:
|
||||||
|
item = {}
|
||||||
|
if isinstance(product.menu_data["Variants"], list):
|
||||||
|
variants = ", ".join(product.menu_data["Variants"])
|
||||||
|
else:
|
||||||
|
variants = product.menu_data["Variants"]
|
||||||
|
item["name"] = product.name
|
||||||
|
item["variants"] = variants
|
||||||
|
product_entries.append(item)
|
||||||
|
|
||||||
|
return product_entries
|
||||||
|
|
||||||
|
|
||||||
|
class DominosProductListView(http.HomeAssistantView):
|
||||||
|
"""View to retrieve product list content."""
|
||||||
|
|
||||||
|
url = "/api/dominos"
|
||||||
|
name = "api:dominos"
|
||||||
|
|
||||||
|
def __init__(self, dominos):
|
||||||
|
"""Initialize suite view."""
|
||||||
|
self.dominos = dominos
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def get(self, request):
|
||||||
|
"""Retrieve if API is running."""
|
||||||
|
return self.json(self.dominos.get_menu())
|
||||||
|
|
||||||
|
|
||||||
|
class DominosOrder(Entity):
|
||||||
|
"""Represents a Dominos order entity."""
|
||||||
|
|
||||||
|
def __init__(self, order_info, dominos):
|
||||||
|
"""Set up the entity."""
|
||||||
|
self._name = order_info["name"]
|
||||||
|
self._product_codes = order_info["codes"]
|
||||||
|
self._orderable = False
|
||||||
|
self.dominos = dominos
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
"""Return the orders name."""
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def product_codes(self):
|
||||||
|
"""Return the orders product codes."""
|
||||||
|
return self._product_codes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def orderable(self):
|
||||||
|
"""Return the true if orderable."""
|
||||||
|
return self._orderable
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
"""Return the state either closed, orderable or unorderable."""
|
||||||
|
if self.dominos.closest_store is None:
|
||||||
|
return "closed"
|
||||||
|
return "orderable" if self._orderable else "unorderable"
|
||||||
|
|
||||||
|
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||||
|
def update(self):
|
||||||
|
"""Update the order state and refreshes the store."""
|
||||||
|
try:
|
||||||
|
self.dominos.update_closest_store()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
self._orderable = False
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
order = self.order()
|
||||||
|
order.pay_with()
|
||||||
|
self._orderable = True
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
self._orderable = False
|
||||||
|
|
||||||
|
def order(self):
|
||||||
|
"""Create the order object."""
|
||||||
|
if self.dominos.closest_store is None:
|
||||||
|
raise HomeAssistantError("No store available")
|
||||||
|
|
||||||
|
order = Order(
|
||||||
|
self.dominos.closest_store,
|
||||||
|
self.dominos.customer,
|
||||||
|
self.dominos.address,
|
||||||
|
self.dominos.country,
|
||||||
|
)
|
||||||
|
|
||||||
|
for code in self._product_codes:
|
||||||
|
order.add_item(code)
|
||||||
|
|
||||||
|
return order
|
||||||
|
|
||||||
|
def place(self):
|
||||||
|
"""Place the order."""
|
||||||
|
try:
|
||||||
|
order = self.order()
|
||||||
|
order.place()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
self._orderable = False
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Attempted to order Dominos - Order invalid or store closed"
|
||||||
|
)
|
||||||
7
homeassistant/components/dominos/icons.json
Normal file
7
homeassistant/components/dominos/icons.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"order": {
|
||||||
|
"service": "mdi:pizza"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
homeassistant/components/dominos/manifest.json
Normal file
11
homeassistant/components/dominos/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"domain": "dominos",
|
||||||
|
"name": "Dominos Pizza",
|
||||||
|
"codeowners": [],
|
||||||
|
"dependencies": ["http"],
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/dominos",
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"loggers": ["pizzapi"],
|
||||||
|
"quality_scale": "legacy",
|
||||||
|
"requirements": ["pizzapi==0.0.6"]
|
||||||
|
}
|
||||||
6
homeassistant/components/dominos/services.yaml
Normal file
6
homeassistant/components/dominos/services.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
order:
|
||||||
|
fields:
|
||||||
|
order_entity_id:
|
||||||
|
example: dominos.medium_pan
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
14
homeassistant/components/dominos/strings.json
Normal file
14
homeassistant/components/dominos/strings.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"services": {
|
||||||
|
"order": {
|
||||||
|
"description": "Places a set of orders with Domino's Pizza.",
|
||||||
|
"fields": {
|
||||||
|
"order_entity_id": {
|
||||||
|
"description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all the identified orders will be placed.",
|
||||||
|
"name": "Order entity"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Order"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
rules:
|
|
||||||
# todo : add get_feed_list to the library
|
|
||||||
# todo : see if we can drop some extra attributes
|
|
||||||
# Bronze
|
|
||||||
action-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide additional actions.
|
|
||||||
appropriate-polling: done
|
|
||||||
brands: done
|
|
||||||
common-modules: done
|
|
||||||
config-flow-test-coverage:
|
|
||||||
status: todo
|
|
||||||
comment: |
|
|
||||||
test_reconfigure_api_error should use a mock config entry fixture
|
|
||||||
test_user_flow_failure should use a mock config entry fixture
|
|
||||||
move test_user_flow_* to the top of the file
|
|
||||||
config-flow: done
|
|
||||||
dependency-transparency: done
|
|
||||||
docs-actions:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide additional actions.
|
|
||||||
docs-high-level-description: done
|
|
||||||
docs-installation-instructions: done
|
|
||||||
docs-removal-instructions: done
|
|
||||||
entity-event-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
No events are explicitly registered by the integration.
|
|
||||||
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: done
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable: todo
|
|
||||||
integration-owner: done
|
|
||||||
log-when-unavailable: done
|
|
||||||
parallel-updates: todo
|
|
||||||
reauthentication-flow: todo
|
|
||||||
test-coverage:
|
|
||||||
status: todo
|
|
||||||
comment: |
|
|
||||||
test the entry state in test_failure
|
|
||||||
|
|
||||||
# Gold
|
|
||||||
devices: todo
|
|
||||||
diagnostics: todo
|
|
||||||
discovery-update-info: todo
|
|
||||||
discovery: todo
|
|
||||||
docs-data-update: done
|
|
||||||
docs-examples:
|
|
||||||
status: exempt
|
|
||||||
comment: |
|
|
||||||
This integration does not provide any automation
|
|
||||||
docs-known-limitations: todo
|
|
||||||
docs-supported-devices: todo
|
|
||||||
docs-supported-functions: done
|
|
||||||
docs-troubleshooting: done
|
|
||||||
docs-use-cases: todo
|
|
||||||
dynamic-devices: todo
|
|
||||||
entity-category: todo
|
|
||||||
entity-device-class:
|
|
||||||
status: todo
|
|
||||||
comment: change device_class=SensorDeviceClass.SIGNAL_STRENGTH to SOUND_PRESSURE
|
|
||||||
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
|
|
||||||
@@ -29,9 +29,9 @@ from homeassistant.const import (
|
|||||||
UnitOfVolumeFlowRate,
|
UnitOfVolumeFlowRate,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import template
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
from homeassistant.util import dt as dt_util
|
|
||||||
|
|
||||||
from .config_flow import sensor_name
|
from .config_flow import sensor_name
|
||||||
from .const import CONF_ONLY_INCLUDE_FEEDID, FEED_ID, FEED_NAME, FEED_TAG
|
from .const import CONF_ONLY_INCLUDE_FEEDID, FEED_ID, FEED_NAME, FEED_TAG
|
||||||
@@ -267,9 +267,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity):
|
|||||||
self._attr_extra_state_attributes[ATTR_USERID] = elem["userid"]
|
self._attr_extra_state_attributes[ATTR_USERID] = elem["userid"]
|
||||||
self._attr_extra_state_attributes[ATTR_LASTUPDATETIME] = elem["time"]
|
self._attr_extra_state_attributes[ATTR_LASTUPDATETIME] = elem["time"]
|
||||||
self._attr_extra_state_attributes[ATTR_LASTUPDATETIMESTR] = (
|
self._attr_extra_state_attributes[ATTR_LASTUPDATETIMESTR] = (
|
||||||
dt_util.as_local(
|
template.timestamp_local(float(elem["time"]))
|
||||||
dt_util.utc_from_timestamp(float(elem["time"]))
|
|
||||||
).isoformat()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._attr_native_value = None
|
self._attr_native_value = None
|
||||||
|
|||||||
@@ -147,8 +147,6 @@ async def async_get_config_entry_diagnostics(
|
|||||||
"ctmeter_production_phases": envoy_data.ctmeter_production_phases,
|
"ctmeter_production_phases": envoy_data.ctmeter_production_phases,
|
||||||
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
|
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
|
||||||
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
|
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
|
||||||
"ctmeters": envoy_data.ctmeters,
|
|
||||||
"ctmeters_phases": envoy_data.ctmeters_phases,
|
|
||||||
"dry_contact_status": envoy_data.dry_contact_status,
|
"dry_contact_status": envoy_data.dry_contact_status,
|
||||||
"dry_contact_settings": envoy_data.dry_contact_settings,
|
"dry_contact_settings": envoy_data.dry_contact_settings,
|
||||||
"inverters": envoy_data.inverters,
|
"inverters": envoy_data.inverters,
|
||||||
@@ -181,7 +179,6 @@ async def async_get_config_entry_diagnostics(
|
|||||||
"ct_consumption_meter": envoy.consumption_meter_type,
|
"ct_consumption_meter": envoy.consumption_meter_type,
|
||||||
"ct_production_meter": envoy.production_meter_type,
|
"ct_production_meter": envoy.production_meter_type,
|
||||||
"ct_storage_meter": envoy.storage_meter_type,
|
"ct_storage_meter": envoy.storage_meter_type,
|
||||||
"ct_meters": list(envoy_data.ctmeters.keys()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fixture_data: dict[str, Any] = {}
|
fixture_data: dict[str, Any] = {}
|
||||||
|
|||||||
@@ -399,189 +399,117 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
|
|||||||
cttype: str | None = None
|
cttype: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# All ct types unified in common setup
|
CT_NET_CONSUMPTION_SENSORS = (
|
||||||
CT_SENSORS = (
|
EnvoyCTSensorEntityDescription(
|
||||||
[
|
key="lifetime_net_consumption",
|
||||||
EnvoyCTSensorEntityDescription(
|
translation_key="lifetime_net_consumption",
|
||||||
key=key,
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||||
translation_key=key,
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||||
device_class=SensorDeviceClass.ENERGY,
|
suggested_display_precision=3,
|
||||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
value_fn=attrgetter("energy_delivered"),
|
||||||
suggested_display_precision=3,
|
on_phase=None,
|
||||||
value_fn=attrgetter("energy_delivered"),
|
cttype=CtType.NET_CONSUMPTION,
|
||||||
on_phase=None,
|
),
|
||||||
cttype=cttype,
|
EnvoyCTSensorEntityDescription(
|
||||||
)
|
key="lifetime_net_production",
|
||||||
for cttype, key in (
|
translation_key="lifetime_net_production",
|
||||||
(CtType.NET_CONSUMPTION, "lifetime_net_consumption"),
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||||
# Production CT energy_delivered is not used
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
(CtType.STORAGE, "lifetime_battery_discharged"),
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
)
|
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||||
]
|
suggested_display_precision=3,
|
||||||
+ [
|
value_fn=attrgetter("energy_received"),
|
||||||
EnvoyCTSensorEntityDescription(
|
on_phase=None,
|
||||||
key=key,
|
cttype=CtType.NET_CONSUMPTION,
|
||||||
translation_key=key,
|
),
|
||||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
EnvoyCTSensorEntityDescription(
|
||||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
key="net_consumption",
|
||||||
device_class=SensorDeviceClass.ENERGY,
|
translation_key="net_consumption",
|
||||||
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
native_unit_of_measurement=UnitOfPower.WATT,
|
||||||
suggested_display_precision=3,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value_fn=attrgetter("energy_received"),
|
device_class=SensorDeviceClass.POWER,
|
||||||
on_phase=None,
|
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||||
cttype=cttype,
|
suggested_display_precision=3,
|
||||||
)
|
value_fn=attrgetter("active_power"),
|
||||||
for cttype, key in (
|
on_phase=None,
|
||||||
(CtType.NET_CONSUMPTION, "lifetime_net_production"),
|
cttype=CtType.NET_CONSUMPTION,
|
||||||
# Production CT energy_received is not used
|
),
|
||||||
(CtType.STORAGE, "lifetime_battery_charged"),
|
EnvoyCTSensorEntityDescription(
|
||||||
)
|
key="frequency",
|
||||||
]
|
translation_key="net_ct_frequency",
|
||||||
+ [
|
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||||
EnvoyCTSensorEntityDescription(
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
key=key,
|
device_class=SensorDeviceClass.FREQUENCY,
|
||||||
translation_key=key,
|
suggested_display_precision=1,
|
||||||
native_unit_of_measurement=UnitOfPower.WATT,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
value_fn=attrgetter("frequency"),
|
||||||
device_class=SensorDeviceClass.POWER,
|
on_phase=None,
|
||||||
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
cttype=CtType.NET_CONSUMPTION,
|
||||||
suggested_display_precision=3,
|
),
|
||||||
value_fn=attrgetter("active_power"),
|
EnvoyCTSensorEntityDescription(
|
||||||
on_phase=None,
|
key="voltage",
|
||||||
cttype=cttype,
|
translation_key="net_ct_voltage",
|
||||||
)
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
for cttype, key in (
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
(CtType.NET_CONSUMPTION, "net_consumption"),
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
# Production CT active_power is not used
|
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
(CtType.STORAGE, "battery_discharge"),
|
suggested_display_precision=1,
|
||||||
)
|
entity_registry_enabled_default=False,
|
||||||
]
|
value_fn=attrgetter("voltage"),
|
||||||
+ [
|
on_phase=None,
|
||||||
EnvoyCTSensorEntityDescription(
|
cttype=CtType.NET_CONSUMPTION,
|
||||||
key=key,
|
),
|
||||||
translation_key=(translation_key if translation_key != "" else key),
|
EnvoyCTSensorEntityDescription(
|
||||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
key="net_ct_current",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
translation_key="net_ct_current",
|
||||||
device_class=SensorDeviceClass.FREQUENCY,
|
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||||
suggested_display_precision=1,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_registry_enabled_default=False,
|
device_class=SensorDeviceClass.CURRENT,
|
||||||
value_fn=attrgetter("frequency"),
|
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||||
on_phase=None,
|
suggested_display_precision=3,
|
||||||
cttype=cttype,
|
entity_registry_enabled_default=False,
|
||||||
)
|
value_fn=attrgetter("current"),
|
||||||
for cttype, key, translation_key in (
|
on_phase=None,
|
||||||
(CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"),
|
cttype=CtType.NET_CONSUMPTION,
|
||||||
(CtType.PRODUCTION, "production_ct_frequency", ""),
|
),
|
||||||
(CtType.STORAGE, "storage_ct_frequency", ""),
|
EnvoyCTSensorEntityDescription(
|
||||||
)
|
key="net_ct_powerfactor",
|
||||||
]
|
translation_key="net_ct_powerfactor",
|
||||||
+ [
|
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||||
EnvoyCTSensorEntityDescription(
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
key=key,
|
suggested_display_precision=2,
|
||||||
translation_key=(translation_key if translation_key != "" else key),
|
entity_registry_enabled_default=False,
|
||||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
value_fn=attrgetter("power_factor"),
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
on_phase=None,
|
||||||
device_class=SensorDeviceClass.VOLTAGE,
|
cttype=CtType.NET_CONSUMPTION,
|
||||||
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
),
|
||||||
suggested_display_precision=1,
|
EnvoyCTSensorEntityDescription(
|
||||||
entity_registry_enabled_default=False,
|
key="net_consumption_ct_metering_status",
|
||||||
value_fn=attrgetter("voltage"),
|
translation_key="net_ct_metering_status",
|
||||||
on_phase=None,
|
device_class=SensorDeviceClass.ENUM,
|
||||||
cttype=cttype,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
)
|
options=list(CtMeterStatus),
|
||||||
for cttype, key, translation_key in (
|
entity_registry_enabled_default=False,
|
||||||
(CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"),
|
value_fn=attrgetter("metering_status"),
|
||||||
(CtType.PRODUCTION, "production_ct_voltage", ""),
|
on_phase=None,
|
||||||
(CtType.STORAGE, "storage_voltage", "storage_ct_voltage"),
|
cttype=CtType.NET_CONSUMPTION,
|
||||||
)
|
),
|
||||||
]
|
EnvoyCTSensorEntityDescription(
|
||||||
+ [
|
key="net_consumption_ct_status_flags",
|
||||||
EnvoyCTSensorEntityDescription(
|
translation_key="net_ct_status_flags",
|
||||||
key=key,
|
state_class=None,
|
||||||
translation_key=key,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
entity_registry_enabled_default=False,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||||
device_class=SensorDeviceClass.CURRENT,
|
on_phase=None,
|
||||||
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
cttype=CtType.NET_CONSUMPTION,
|
||||||
suggested_display_precision=3,
|
),
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("current"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=cttype,
|
|
||||||
)
|
|
||||||
for cttype, key in (
|
|
||||||
(CtType.NET_CONSUMPTION, "net_ct_current"),
|
|
||||||
(CtType.PRODUCTION, "production_ct_current"),
|
|
||||||
(CtType.STORAGE, "storage_ct_current"),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
+ [
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key=key,
|
|
||||||
translation_key=key,
|
|
||||||
device_class=SensorDeviceClass.POWER_FACTOR,
|
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
|
||||||
suggested_display_precision=2,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("power_factor"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=cttype,
|
|
||||||
)
|
|
||||||
for cttype, key in (
|
|
||||||
(CtType.NET_CONSUMPTION, "net_ct_powerfactor"),
|
|
||||||
(CtType.PRODUCTION, "production_ct_powerfactor"),
|
|
||||||
(CtType.STORAGE, "storage_ct_powerfactor"),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
+ [
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key=key,
|
|
||||||
translation_key=(translation_key if translation_key != "" else key),
|
|
||||||
device_class=SensorDeviceClass.ENUM,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
options=list(CtMeterStatus),
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=attrgetter("metering_status"),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=cttype,
|
|
||||||
)
|
|
||||||
for cttype, key, translation_key in (
|
|
||||||
(
|
|
||||||
CtType.NET_CONSUMPTION,
|
|
||||||
"net_consumption_ct_metering_status",
|
|
||||||
"net_ct_metering_status",
|
|
||||||
),
|
|
||||||
(CtType.PRODUCTION, "production_ct_metering_status", ""),
|
|
||||||
(CtType.STORAGE, "storage_ct_metering_status", ""),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
+ [
|
|
||||||
EnvoyCTSensorEntityDescription(
|
|
||||||
key=key,
|
|
||||||
translation_key=(translation_key if translation_key != "" else key),
|
|
||||||
state_class=None,
|
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
|
||||||
entity_registry_enabled_default=False,
|
|
||||||
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
|
||||||
on_phase=None,
|
|
||||||
cttype=cttype,
|
|
||||||
)
|
|
||||||
for cttype, key, translation_key in (
|
|
||||||
(
|
|
||||||
CtType.NET_CONSUMPTION,
|
|
||||||
"net_consumption_ct_status_flags",
|
|
||||||
"net_ct_status_flags",
|
|
||||||
),
|
|
||||||
(CtType.PRODUCTION, "production_ct_status_flags", ""),
|
|
||||||
(CtType.STORAGE, "storage_ct_status_flags", ""),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
CT_PHASE_SENSORS = {
|
CT_NET_CONSUMPTION_PHASE_SENSORS = {
|
||||||
(on_phase := PHASENAMES[phase]): [
|
(on_phase := PHASENAMES[phase]): [
|
||||||
replace(
|
replace(
|
||||||
sensor,
|
sensor,
|
||||||
@@ -591,7 +519,220 @@ CT_PHASE_SENSORS = {
|
|||||||
on_phase=on_phase,
|
on_phase=on_phase,
|
||||||
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||||
)
|
)
|
||||||
for sensor in list(CT_SENSORS)
|
for sensor in list(CT_NET_CONSUMPTION_SENSORS)
|
||||||
|
]
|
||||||
|
for phase in range(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
CT_PRODUCTION_SENSORS = (
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="production_ct_frequency",
|
||||||
|
translation_key="production_ct_frequency",
|
||||||
|
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
device_class=SensorDeviceClass.FREQUENCY,
|
||||||
|
suggested_display_precision=1,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("frequency"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.PRODUCTION,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="production_ct_voltage",
|
||||||
|
translation_key="production_ct_voltage",
|
||||||
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
|
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
|
suggested_display_precision=1,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("voltage"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.PRODUCTION,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="production_ct_current",
|
||||||
|
translation_key="production_ct_current",
|
||||||
|
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
device_class=SensorDeviceClass.CURRENT,
|
||||||
|
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||||
|
suggested_display_precision=3,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("current"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.PRODUCTION,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="production_ct_powerfactor",
|
||||||
|
translation_key="production_ct_powerfactor",
|
||||||
|
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=2,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("power_factor"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.PRODUCTION,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="production_ct_metering_status",
|
||||||
|
translation_key="production_ct_metering_status",
|
||||||
|
device_class=SensorDeviceClass.ENUM,
|
||||||
|
options=list(CtMeterStatus),
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("metering_status"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.PRODUCTION,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="production_ct_status_flags",
|
||||||
|
translation_key="production_ct_status_flags",
|
||||||
|
state_class=None,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.PRODUCTION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
CT_PRODUCTION_PHASE_SENSORS = {
|
||||||
|
(on_phase := PHASENAMES[phase]): [
|
||||||
|
replace(
|
||||||
|
sensor,
|
||||||
|
key=f"{sensor.key}_l{phase + 1}",
|
||||||
|
translation_key=f"{sensor.translation_key}_phase",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
on_phase=on_phase,
|
||||||
|
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||||
|
)
|
||||||
|
for sensor in list(CT_PRODUCTION_SENSORS)
|
||||||
|
]
|
||||||
|
for phase in range(3)
|
||||||
|
}
|
||||||
|
|
||||||
|
CT_STORAGE_SENSORS = (
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="lifetime_battery_discharged",
|
||||||
|
translation_key="lifetime_battery_discharged",
|
||||||
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||||
|
suggested_display_precision=3,
|
||||||
|
value_fn=attrgetter("energy_delivered"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.STORAGE,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="lifetime_battery_charged",
|
||||||
|
translation_key="lifetime_battery_charged",
|
||||||
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||||
|
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
|
||||||
|
suggested_display_precision=3,
|
||||||
|
value_fn=attrgetter("energy_received"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.STORAGE,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="battery_discharge",
|
||||||
|
translation_key="battery_discharge",
|
||||||
|
native_unit_of_measurement=UnitOfPower.WATT,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
device_class=SensorDeviceClass.POWER,
|
||||||
|
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||||
|
suggested_display_precision=3,
|
||||||
|
value_fn=attrgetter("active_power"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.STORAGE,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="storage_ct_frequency",
|
||||||
|
translation_key="storage_ct_frequency",
|
||||||
|
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
device_class=SensorDeviceClass.FREQUENCY,
|
||||||
|
suggested_display_precision=1,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("frequency"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.STORAGE,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="storage_voltage",
|
||||||
|
translation_key="storage_ct_voltage",
|
||||||
|
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
device_class=SensorDeviceClass.VOLTAGE,
|
||||||
|
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||||
|
suggested_display_precision=1,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("voltage"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.STORAGE,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="storage_ct_current",
|
||||||
|
translation_key="storage_ct_current",
|
||||||
|
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
device_class=SensorDeviceClass.CURRENT,
|
||||||
|
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||||
|
suggested_display_precision=3,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("current"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.STORAGE,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="storage_ct_powerfactor",
|
||||||
|
translation_key="storage_ct_powerfactor",
|
||||||
|
device_class=SensorDeviceClass.POWER_FACTOR,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
suggested_display_precision=2,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("power_factor"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.STORAGE,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="storage_ct_metering_status",
|
||||||
|
translation_key="storage_ct_metering_status",
|
||||||
|
device_class=SensorDeviceClass.ENUM,
|
||||||
|
options=list(CtMeterStatus),
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=attrgetter("metering_status"),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.STORAGE,
|
||||||
|
),
|
||||||
|
EnvoyCTSensorEntityDescription(
|
||||||
|
key="storage_ct_status_flags",
|
||||||
|
translation_key="storage_ct_status_flags",
|
||||||
|
state_class=None,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
|
||||||
|
on_phase=None,
|
||||||
|
cttype=CtType.STORAGE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CT_STORAGE_PHASE_SENSORS = {
|
||||||
|
(on_phase := PHASENAMES[phase]): [
|
||||||
|
replace(
|
||||||
|
sensor,
|
||||||
|
key=f"{sensor.key}_l{phase + 1}",
|
||||||
|
translation_key=f"{sensor.translation_key}_phase",
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
on_phase=on_phase,
|
||||||
|
translation_placeholders={"phase_name": f"l{phase + 1}"},
|
||||||
|
)
|
||||||
|
for sensor in list(CT_STORAGE_SENSORS)
|
||||||
]
|
]
|
||||||
for phase in range(3)
|
for phase in range(3)
|
||||||
}
|
}
|
||||||
@@ -919,14 +1060,24 @@ async def async_setup_entry(
|
|||||||
if envoy_data.ctmeters:
|
if envoy_data.ctmeters:
|
||||||
entities.extend(
|
entities.extend(
|
||||||
EnvoyCTEntity(coordinator, description)
|
EnvoyCTEntity(coordinator, description)
|
||||||
for description in CT_SENSORS
|
for sensors in (
|
||||||
|
CT_NET_CONSUMPTION_SENSORS,
|
||||||
|
CT_PRODUCTION_SENSORS,
|
||||||
|
CT_STORAGE_SENSORS,
|
||||||
|
)
|
||||||
|
for description in sensors
|
||||||
if description.cttype in envoy_data.ctmeters
|
if description.cttype in envoy_data.ctmeters
|
||||||
)
|
)
|
||||||
# Add Current Transformer phase entities
|
# Add Current Transformer phase entities
|
||||||
if ctmeters_phases := envoy_data.ctmeters_phases:
|
if ctmeters_phases := envoy_data.ctmeters_phases:
|
||||||
entities.extend(
|
entities.extend(
|
||||||
EnvoyCTPhaseEntity(coordinator, description)
|
EnvoyCTPhaseEntity(coordinator, description)
|
||||||
for phase, descriptions in CT_PHASE_SENSORS.items()
|
for sensors in (
|
||||||
|
CT_NET_CONSUMPTION_PHASE_SENSORS,
|
||||||
|
CT_PRODUCTION_PHASE_SENSORS,
|
||||||
|
CT_STORAGE_PHASE_SENSORS,
|
||||||
|
)
|
||||||
|
for phase, descriptions in sensors.items()
|
||||||
for description in descriptions
|
for description in descriptions
|
||||||
if (cttype := description.cttype) in ctmeters_phases
|
if (cttype := description.cttype) in ctmeters_phases
|
||||||
and phase in ctmeters_phases[cttype]
|
and phase in ctmeters_phases[cttype]
|
||||||
|
|||||||
@@ -777,9 +777,7 @@ class ManifestJSONView(HomeAssistantView):
|
|||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
"type": "frontend/get_icons",
|
"type": "frontend/get_icons",
|
||||||
vol.Required("category"): vol.In(
|
vol.Required("category"): vol.In({"entity", "entity_component", "services"}),
|
||||||
{"entity", "entity_component", "services", "triggers", "conditions"}
|
|
||||||
),
|
|
||||||
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
|
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20251105.1"]
|
"requirements": ["home-assistant-frontend==20251105.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,11 @@ import voluptuous as vol
|
|||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.components.websocket_api import ActiveConnection
|
from homeassistant.components.websocket_api import ActiveConnection
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers import singleton
|
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
from homeassistant.util.hass_dict import HassKey
|
from homeassistant.util.hass_dict import HassKey
|
||||||
|
|
||||||
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
|
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
|
||||||
DATA_SYSTEM_STORAGE: HassKey[SystemStore] = HassKey("frontend_system_storage")
|
|
||||||
STORAGE_VERSION_USER_DATA = 1
|
STORAGE_VERSION_USER_DATA = 1
|
||||||
STORAGE_VERSION_SYSTEM_DATA = 1
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
|
async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
|
||||||
@@ -26,9 +23,6 @@ async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
|
|||||||
websocket_api.async_register_command(hass, websocket_set_user_data)
|
websocket_api.async_register_command(hass, websocket_set_user_data)
|
||||||
websocket_api.async_register_command(hass, websocket_get_user_data)
|
websocket_api.async_register_command(hass, websocket_get_user_data)
|
||||||
websocket_api.async_register_command(hass, websocket_subscribe_user_data)
|
websocket_api.async_register_command(hass, websocket_subscribe_user_data)
|
||||||
websocket_api.async_register_command(hass, websocket_set_system_data)
|
|
||||||
websocket_api.async_register_command(hass, websocket_get_system_data)
|
|
||||||
websocket_api.async_register_command(hass, websocket_subscribe_system_data)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
|
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
|
||||||
@@ -89,52 +83,6 @@ class _UserStore(Store[dict[str, Any]]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@singleton.singleton(DATA_SYSTEM_STORAGE, async_=True)
|
|
||||||
async def async_system_store(hass: HomeAssistant) -> SystemStore:
|
|
||||||
"""Access the system store."""
|
|
||||||
store = SystemStore(hass)
|
|
||||||
await store.async_load()
|
|
||||||
return store
|
|
||||||
|
|
||||||
|
|
||||||
class SystemStore:
|
|
||||||
"""System store for frontend data."""
|
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
|
||||||
"""Initialize the system store."""
|
|
||||||
self._store: Store[dict[str, Any]] = Store(
|
|
||||||
hass,
|
|
||||||
STORAGE_VERSION_SYSTEM_DATA,
|
|
||||||
"frontend.system_data",
|
|
||||||
)
|
|
||||||
self.data: dict[str, Any] = {}
|
|
||||||
self.subscriptions: dict[str, list[Callable[[], None]]] = {}
|
|
||||||
|
|
||||||
async def async_load(self) -> None:
|
|
||||||
"""Load the data from the store."""
|
|
||||||
self.data = await self._store.async_load() or {}
|
|
||||||
|
|
||||||
async def async_set_item(self, key: str, value: Any) -> None:
|
|
||||||
"""Set an item and save the store."""
|
|
||||||
self.data[key] = value
|
|
||||||
self._store.async_delay_save(lambda: self.data, 1.0)
|
|
||||||
for cb in self.subscriptions.get(key, []):
|
|
||||||
cb()
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_subscribe(
|
|
||||||
self, key: str, on_update_callback: Callable[[], None]
|
|
||||||
) -> Callable[[], None]:
|
|
||||||
"""Subscribe to store updates."""
|
|
||||||
self.subscriptions.setdefault(key, []).append(on_update_callback)
|
|
||||||
|
|
||||||
def unsubscribe() -> None:
|
|
||||||
"""Unsubscribe from the store."""
|
|
||||||
self.subscriptions[key].remove(on_update_callback)
|
|
||||||
|
|
||||||
return unsubscribe
|
|
||||||
|
|
||||||
|
|
||||||
def with_user_store(
|
def with_user_store(
|
||||||
orig_func: Callable[
|
orig_func: Callable[
|
||||||
[HomeAssistant, ActiveConnection, dict[str, Any], UserStore],
|
[HomeAssistant, ActiveConnection, dict[str, Any], UserStore],
|
||||||
@@ -159,28 +107,6 @@ def with_user_store(
|
|||||||
return with_user_store_func
|
return with_user_store_func
|
||||||
|
|
||||||
|
|
||||||
def with_system_store(
|
|
||||||
orig_func: Callable[
|
|
||||||
[HomeAssistant, ActiveConnection, dict[str, Any], SystemStore],
|
|
||||||
Coroutine[Any, Any, None],
|
|
||||||
],
|
|
||||||
) -> Callable[
|
|
||||||
[HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None]
|
|
||||||
]:
|
|
||||||
"""Decorate function to provide system store."""
|
|
||||||
|
|
||||||
@wraps(orig_func)
|
|
||||||
async def with_system_store_func(
|
|
||||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
|
||||||
) -> None:
|
|
||||||
"""Provide system store to function."""
|
|
||||||
store = await async_system_store(hass)
|
|
||||||
|
|
||||||
await orig_func(hass, connection, msg, store)
|
|
||||||
|
|
||||||
return with_system_store_func
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{
|
{
|
||||||
vol.Required("type"): "frontend/set_user_data",
|
vol.Required("type"): "frontend/set_user_data",
|
||||||
@@ -243,65 +169,3 @@ async def websocket_subscribe_user_data(
|
|||||||
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
|
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
|
||||||
on_data_update()
|
on_data_update()
|
||||||
connection.send_result(msg["id"])
|
connection.send_result(msg["id"])
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{
|
|
||||||
vol.Required("type"): "frontend/set_system_data",
|
|
||||||
vol.Required("key"): str,
|
|
||||||
vol.Required("value"): vol.Any(bool, str, int, float, dict, list, None),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@websocket_api.require_admin
|
|
||||||
@websocket_api.async_response
|
|
||||||
@with_system_store
|
|
||||||
async def websocket_set_system_data(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: ActiveConnection,
|
|
||||||
msg: dict[str, Any],
|
|
||||||
store: SystemStore,
|
|
||||||
) -> None:
|
|
||||||
"""Handle set system data command."""
|
|
||||||
await store.async_set_item(msg["key"], msg["value"])
|
|
||||||
connection.send_result(msg["id"])
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{vol.Required("type"): "frontend/get_system_data", vol.Required("key"): str}
|
|
||||||
)
|
|
||||||
@websocket_api.async_response
|
|
||||||
@with_system_store
|
|
||||||
async def websocket_get_system_data(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: ActiveConnection,
|
|
||||||
msg: dict[str, Any],
|
|
||||||
store: SystemStore,
|
|
||||||
) -> None:
|
|
||||||
"""Handle get system data command."""
|
|
||||||
connection.send_result(msg["id"], {"value": store.data.get(msg["key"])})
|
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
|
||||||
{
|
|
||||||
vol.Required("type"): "frontend/subscribe_system_data",
|
|
||||||
vol.Required("key"): str,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@websocket_api.async_response
|
|
||||||
@with_system_store
|
|
||||||
async def websocket_subscribe_system_data(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
connection: ActiveConnection,
|
|
||||||
msg: dict[str, Any],
|
|
||||||
store: SystemStore,
|
|
||||||
) -> None:
|
|
||||||
"""Handle subscribe to system data command."""
|
|
||||||
key: str = msg["key"]
|
|
||||||
|
|
||||||
def on_data_update() -> None:
|
|
||||||
"""Handle system data update."""
|
|
||||||
connection.send_event(msg["id"], {"value": store.data.get(key)})
|
|
||||||
|
|
||||||
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
|
|
||||||
on_data_update()
|
|
||||||
connection.send_result(msg["id"])
|
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/generic",
|
"documentation": "https://www.home-assistant.io/integrations/generic",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["av==16.0.1", "Pillow==12.0.0"]
|
"requirements": ["av==13.1.0", "Pillow==12.0.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from aiohttp import ClientSession, UnixConnector
|
from aiohttp import ClientSession
|
||||||
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
from go2rtc_client import Go2RtcRestClient
|
from go2rtc_client import Go2RtcRestClient
|
||||||
@@ -53,7 +52,6 @@ from .const import (
|
|||||||
CONF_DEBUG_UI,
|
CONF_DEBUG_UI,
|
||||||
DEBUG_UI_URL_MESSAGE,
|
DEBUG_UI_URL_MESSAGE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
HA_MANAGED_UNIX_SOCKET,
|
|
||||||
HA_MANAGED_URL,
|
HA_MANAGED_URL,
|
||||||
RECOMMENDED_VERSION,
|
RECOMMENDED_VERSION,
|
||||||
)
|
)
|
||||||
@@ -62,6 +60,35 @@ from .server import Server
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
_FFMPEG = "ffmpeg"
|
_FFMPEG = "ffmpeg"
|
||||||
|
_SUPPORTED_STREAMS = frozenset(
|
||||||
|
(
|
||||||
|
"bubble",
|
||||||
|
"dvrip",
|
||||||
|
"expr",
|
||||||
|
_FFMPEG,
|
||||||
|
"gopro",
|
||||||
|
"homekit",
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"httpx",
|
||||||
|
"isapi",
|
||||||
|
"ivideon",
|
||||||
|
"kasa",
|
||||||
|
"nest",
|
||||||
|
"onvif",
|
||||||
|
"roborock",
|
||||||
|
"rtmp",
|
||||||
|
"rtmps",
|
||||||
|
"rtmpx",
|
||||||
|
"rtsp",
|
||||||
|
"rtsps",
|
||||||
|
"rtspx",
|
||||||
|
"tapo",
|
||||||
|
"tcp",
|
||||||
|
"webrtc",
|
||||||
|
"webtorrent",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
@@ -75,7 +102,7 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
_DATA_GO2RTC: HassKey[Go2RtcConfig] = HassKey(DOMAIN)
|
_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN)
|
||||||
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
|
_RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError)
|
||||||
type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider]
|
type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider]
|
||||||
|
|
||||||
@@ -102,12 +129,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# HA will manage the binary
|
# HA will manage the binary
|
||||||
session = ClientSession(connector=UnixConnector(path=HA_MANAGED_UNIX_SOCKET))
|
|
||||||
server = Server(
|
server = Server(
|
||||||
hass,
|
hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False)
|
||||||
binary,
|
|
||||||
session,
|
|
||||||
enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False),
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await server.start()
|
await server.start()
|
||||||
@@ -117,15 +140,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
async def on_stop(event: Event) -> None:
|
async def on_stop(event: Event) -> None:
|
||||||
await server.stop()
|
await server.stop()
|
||||||
await session.close()
|
|
||||||
|
|
||||||
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
|
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)
|
||||||
|
|
||||||
url = HA_MANAGED_URL
|
url = HA_MANAGED_URL
|
||||||
else:
|
|
||||||
session = async_get_clientsession(hass)
|
|
||||||
|
|
||||||
hass.data[_DATA_GO2RTC] = Go2RtcConfig(url, session)
|
hass.data[_DATA_GO2RTC] = url
|
||||||
discovery_flow.async_create_flow(
|
discovery_flow.async_create_flow(
|
||||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||||
)
|
)
|
||||||
@@ -141,9 +161,8 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None:
|
|||||||
async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool:
|
||||||
"""Set up go2rtc from a config entry."""
|
"""Set up go2rtc from a config entry."""
|
||||||
|
|
||||||
config = hass.data[_DATA_GO2RTC]
|
url = hass.data[_DATA_GO2RTC]
|
||||||
url = config.url
|
session = async_get_clientsession(hass)
|
||||||
session = config.session
|
|
||||||
client = Go2RtcRestClient(session, url)
|
client = Go2RtcRestClient(session, url)
|
||||||
# Validate the server URL
|
# Validate the server URL
|
||||||
try:
|
try:
|
||||||
@@ -178,7 +197,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
|
provider = entry.runtime_data = WebRTCProvider(hass, url, session, client)
|
||||||
await provider.initialize()
|
|
||||||
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
|
entry.async_on_unload(async_register_webrtc_provider(hass, provider))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -210,21 +228,16 @@ class WebRTCProvider(CameraWebRTCProvider):
|
|||||||
self._session = session
|
self._session = session
|
||||||
self._rest_client = rest_client
|
self._rest_client = rest_client
|
||||||
self._sessions: dict[str, Go2RtcWsClient] = {}
|
self._sessions: dict[str, Go2RtcWsClient] = {}
|
||||||
self._supported_schemes: set[str] = set()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def domain(self) -> str:
|
def domain(self) -> str:
|
||||||
"""Return the integration domain of the provider."""
|
"""Return the integration domain of the provider."""
|
||||||
return DOMAIN
|
return DOMAIN
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
"""Initialize the provider."""
|
|
||||||
self._supported_schemes = await self._rest_client.schemes.list()
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_is_supported(self, stream_source: str) -> bool:
|
def async_is_supported(self, stream_source: str) -> bool:
|
||||||
"""Return if this provider is supports the Camera as source."""
|
"""Return if this provider is supports the Camera as source."""
|
||||||
return stream_source.partition(":")[0] in self._supported_schemes
|
return stream_source.partition(":")[0] in _SUPPORTED_STREAMS
|
||||||
|
|
||||||
async def async_handle_async_webrtc_offer(
|
async def async_handle_async_webrtc_offer(
|
||||||
self,
|
self,
|
||||||
@@ -352,11 +365,3 @@ class WebRTCProvider(CameraWebRTCProvider):
|
|||||||
for ws_client in self._sessions.values():
|
for ws_client in self._sessions.values():
|
||||||
await ws_client.close()
|
await ws_client.close()
|
||||||
self._sessions.clear()
|
self._sessions.clear()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Go2RtcConfig:
|
|
||||||
"""Go2rtc configuration."""
|
|
||||||
|
|
||||||
url: str
|
|
||||||
session: ClientSession
|
|
||||||
|
|||||||
@@ -6,5 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
|
|||||||
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
|
||||||
HA_MANAGED_API_PORT = 11984
|
HA_MANAGED_API_PORT = 11984
|
||||||
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
|
||||||
HA_MANAGED_UNIX_SOCKET = "/run/go2rtc.sock"
|
RECOMMENDED_VERSION = "1.9.11"
|
||||||
RECOMMENDED_VERSION = "1.9.12"
|
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["go2rtc-client==0.3.0"],
|
"requirements": ["go2rtc-client==0.2.1"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from contextlib import suppress
|
|||||||
import logging
|
import logging
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
from aiohttp import ClientSession
|
|
||||||
from go2rtc_client import Go2RtcRestClient
|
from go2rtc_client import Go2RtcRestClient
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import HA_MANAGED_API_PORT, HA_MANAGED_UNIX_SOCKET, HA_MANAGED_URL
|
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
_TERMINATE_TIMEOUT = 5
|
_TERMINATE_TIMEOUT = 5
|
||||||
@@ -23,26 +23,14 @@ _LOG_BUFFER_SIZE = 512
|
|||||||
_RESPAWN_COOLDOWN = 1
|
_RESPAWN_COOLDOWN = 1
|
||||||
|
|
||||||
# Default configuration for HA
|
# Default configuration for HA
|
||||||
# - Unix socket for secure local communication
|
# - Api is listening only on localhost
|
||||||
# - HTTP API only enabled when UI is enabled
|
|
||||||
# - Enable rtsp for localhost only as ffmpeg needs it
|
# - Enable rtsp for localhost only as ffmpeg needs it
|
||||||
# - Clear default ice servers
|
# - Clear default ice servers
|
||||||
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
|
_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant
|
||||||
# Do not edit it manually
|
# Do not edit it manually
|
||||||
|
|
||||||
app:
|
|
||||||
modules: {app_modules}
|
|
||||||
|
|
||||||
api:
|
api:
|
||||||
listen: "{listen_config}"
|
listen: "{api_ip}:{api_port}"
|
||||||
unix_listen: "{unix_socket}"
|
|
||||||
allow_paths: {api_allow_paths}
|
|
||||||
|
|
||||||
# ffmpeg needs the exec module
|
|
||||||
# Restrict execution to only ffmpeg binary
|
|
||||||
exec:
|
|
||||||
allow_paths:
|
|
||||||
- ffmpeg
|
|
||||||
|
|
||||||
rtsp:
|
rtsp:
|
||||||
listen: "127.0.0.1:18554"
|
listen: "127.0.0.1:18554"
|
||||||
@@ -52,43 +40,6 @@ webrtc:
|
|||||||
ice_servers: []
|
ice_servers: []
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_APP_MODULES = (
|
|
||||||
"api",
|
|
||||||
"exec", # Execution module for ffmpeg
|
|
||||||
"ffmpeg",
|
|
||||||
"http",
|
|
||||||
"mjpeg",
|
|
||||||
"onvif",
|
|
||||||
"rtmp",
|
|
||||||
"rtsp",
|
|
||||||
"srtp",
|
|
||||||
"webrtc",
|
|
||||||
"ws",
|
|
||||||
)
|
|
||||||
|
|
||||||
_API_ALLOW_PATHS = (
|
|
||||||
"/", # UI static page and version control
|
|
||||||
"/api", # Main API path
|
|
||||||
"/api/frame.jpeg", # Snapshot functionality
|
|
||||||
"/api/schemes", # Supported stream schemes
|
|
||||||
"/api/streams", # Stream management
|
|
||||||
"/api/webrtc", # Webrtc functionality
|
|
||||||
"/api/ws", # Websocket functionality (e.g. webrtc candidates)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Additional modules when UI is enabled
|
|
||||||
_UI_APP_MODULES = (
|
|
||||||
*_APP_MODULES,
|
|
||||||
"debug",
|
|
||||||
)
|
|
||||||
# Additional api paths when UI is enabled
|
|
||||||
_UI_API_ALLOW_PATHS = (
|
|
||||||
*_API_ALLOW_PATHS,
|
|
||||||
"/api/config", # UI config view
|
|
||||||
"/api/log", # UI log view
|
|
||||||
"/api/streams.dot", # UI network view
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOG_LEVEL_MAP = {
|
_LOG_LEVEL_MAP = {
|
||||||
"TRC": logging.DEBUG,
|
"TRC": logging.DEBUG,
|
||||||
"DBG": logging.DEBUG,
|
"DBG": logging.DEBUG,
|
||||||
@@ -110,38 +61,14 @@ class Go2RTCWatchdogError(HomeAssistantError):
|
|||||||
"""Raised on watchdog error."""
|
"""Raised on watchdog error."""
|
||||||
|
|
||||||
|
|
||||||
def _format_list_for_yaml(items: tuple[str, ...]) -> str:
|
def _create_temp_file(api_ip: str) -> str:
|
||||||
"""Format a list of strings for yaml config."""
|
|
||||||
if not items:
|
|
||||||
return "[]"
|
|
||||||
formatted_items = ",".join(f'"{item}"' for item in items)
|
|
||||||
return f"[{formatted_items}]"
|
|
||||||
|
|
||||||
|
|
||||||
def _create_temp_file(enable_ui: bool) -> str:
|
|
||||||
"""Create temporary config file."""
|
"""Create temporary config file."""
|
||||||
app_modules: tuple[str, ...] = _APP_MODULES
|
|
||||||
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
|
|
||||||
|
|
||||||
if enable_ui:
|
|
||||||
app_modules = _UI_APP_MODULES
|
|
||||||
api_paths = _UI_API_ALLOW_PATHS
|
|
||||||
# Listen on all interfaces for allowing access from all ips
|
|
||||||
listen_config = f":{HA_MANAGED_API_PORT}"
|
|
||||||
else:
|
|
||||||
# Disable HTTP listening when UI is not enabled
|
|
||||||
# as HA does not use it.
|
|
||||||
listen_config = ""
|
|
||||||
|
|
||||||
# Set delete=False to prevent the file from being deleted when the file is closed
|
# Set delete=False to prevent the file from being deleted when the file is closed
|
||||||
# Linux is clearing tmp folder on reboot, so no need to delete it manually
|
# Linux is clearing tmp folder on reboot, so no need to delete it manually
|
||||||
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
|
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
|
||||||
file.write(
|
file.write(
|
||||||
_GO2RTC_CONFIG_FORMAT.format(
|
_GO2RTC_CONFIG_FORMAT.format(
|
||||||
listen_config=listen_config,
|
api_ip=api_ip, api_port=HA_MANAGED_API_PORT
|
||||||
unix_socket=HA_MANAGED_UNIX_SOCKET,
|
|
||||||
app_modules=_format_list_for_yaml(app_modules),
|
|
||||||
api_allow_paths=_format_list_for_yaml(api_paths),
|
|
||||||
).encode()
|
).encode()
|
||||||
)
|
)
|
||||||
return file.name
|
return file.name
|
||||||
@@ -151,21 +78,18 @@ class Server:
|
|||||||
"""Go2rtc server."""
|
"""Go2rtc server."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self, hass: HomeAssistant, binary: str, *, enable_ui: bool = False
|
||||||
hass: HomeAssistant,
|
|
||||||
binary: str,
|
|
||||||
session: ClientSession,
|
|
||||||
*,
|
|
||||||
enable_ui: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the server."""
|
"""Initialize the server."""
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._binary = binary
|
self._binary = binary
|
||||||
self._session = session
|
|
||||||
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
|
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
|
||||||
self._process: asyncio.subprocess.Process | None = None
|
self._process: asyncio.subprocess.Process | None = None
|
||||||
self._startup_complete = asyncio.Event()
|
self._startup_complete = asyncio.Event()
|
||||||
self._enable_ui = enable_ui
|
self._api_ip = _LOCALHOST_IP
|
||||||
|
if enable_ui:
|
||||||
|
# Listen on all interfaces for allowing access from all ips
|
||||||
|
self._api_ip = ""
|
||||||
self._watchdog_task: asyncio.Task | None = None
|
self._watchdog_task: asyncio.Task | None = None
|
||||||
self._watchdog_tasks: list[asyncio.Task] = []
|
self._watchdog_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
@@ -180,7 +104,7 @@ class Server:
|
|||||||
"""Start the server."""
|
"""Start the server."""
|
||||||
_LOGGER.debug("Starting go2rtc server")
|
_LOGGER.debug("Starting go2rtc server")
|
||||||
config_file = await self._hass.async_add_executor_job(
|
config_file = await self._hass.async_add_executor_job(
|
||||||
_create_temp_file, self._enable_ui
|
_create_temp_file, self._api_ip
|
||||||
)
|
)
|
||||||
|
|
||||||
self._startup_complete.clear()
|
self._startup_complete.clear()
|
||||||
@@ -209,7 +133,7 @@ class Server:
|
|||||||
raise Go2RTCServerStartError from err
|
raise Go2RTCServerStartError from err
|
||||||
|
|
||||||
# Check the server version
|
# Check the server version
|
||||||
client = Go2RtcRestClient(self._session, HA_MANAGED_URL)
|
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
|
||||||
await client.validate_server_version()
|
await client.validate_server_version()
|
||||||
|
|
||||||
async def _log_output(self, process: asyncio.subprocess.Process) -> None:
|
async def _log_output(self, process: asyncio.subprocess.Process) -> None:
|
||||||
@@ -281,7 +205,7 @@ class Server:
|
|||||||
|
|
||||||
async def _monitor_api(self) -> None:
|
async def _monitor_api(self) -> None:
|
||||||
"""Raise if the go2rtc process terminates."""
|
"""Raise if the go2rtc process terminates."""
|
||||||
client = Go2RtcRestClient(self._session, HA_MANAGED_URL)
|
client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL)
|
||||||
|
|
||||||
_LOGGER.debug("Monitoring go2rtc API")
|
_LOGGER.debug("Monitoring go2rtc API")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
"""The Goodwe inverter component."""
|
"""The Goodwe inverter component."""
|
||||||
|
|
||||||
from goodwe import Inverter, InverterError, connect
|
from goodwe import InverterError, connect
|
||||||
from goodwe.const import GOODWE_UDP_PORT
|
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
|
||||||
|
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
from homeassistant.const import CONF_HOST
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
|
||||||
from .config_flow import GoodweFlowHandler
|
|
||||||
from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS
|
from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS
|
||||||
from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator
|
from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator
|
||||||
|
|
||||||
@@ -16,22 +15,28 @@ from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoord
|
|||||||
async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool:
|
||||||
"""Set up the Goodwe components from a config entry."""
|
"""Set up the Goodwe components from a config entry."""
|
||||||
host = entry.data[CONF_HOST]
|
host = entry.data[CONF_HOST]
|
||||||
port = entry.data.get(CONF_PORT, GOODWE_UDP_PORT)
|
|
||||||
model_family = entry.data[CONF_MODEL_FAMILY]
|
model_family = entry.data[CONF_MODEL_FAMILY]
|
||||||
|
|
||||||
# Connect to Goodwe inverter
|
# Connect to Goodwe inverter
|
||||||
try:
|
try:
|
||||||
inverter = await connect(
|
inverter = await connect(
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=GOODWE_UDP_PORT,
|
||||||
family=model_family,
|
family=model_family,
|
||||||
retries=10,
|
retries=10,
|
||||||
)
|
)
|
||||||
except InverterError as err:
|
except InverterError as err_udp:
|
||||||
|
# First try with UDP failed, trying with the TCP port
|
||||||
try:
|
try:
|
||||||
inverter = await async_check_port(hass, entry, host)
|
inverter = await connect(
|
||||||
|
host=host,
|
||||||
|
port=GOODWE_TCP_PORT,
|
||||||
|
family=model_family,
|
||||||
|
retries=10,
|
||||||
|
)
|
||||||
except InverterError:
|
except InverterError:
|
||||||
raise ConfigEntryNotReady from err
|
# Both ports are unavailable
|
||||||
|
raise ConfigEntryNotReady from err_udp
|
||||||
|
|
||||||
device_info = DeviceInfo(
|
device_info = DeviceInfo(
|
||||||
configuration_url="https://www.semsportal.com",
|
configuration_url="https://www.semsportal.com",
|
||||||
@@ -61,23 +66,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_check_port(
|
|
||||||
hass: HomeAssistant, entry: GoodweConfigEntry, host: str
|
|
||||||
) -> Inverter:
|
|
||||||
"""Check the communication port of the inverter, it may have changed after a firmware update."""
|
|
||||||
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(host=host)
|
|
||||||
family = type(inverter).__name__
|
|
||||||
hass.config_entries.async_update_entry(
|
|
||||||
entry,
|
|
||||||
data={
|
|
||||||
CONF_HOST: host,
|
|
||||||
CONF_PORT: port,
|
|
||||||
CONF_MODEL_FAMILY: family,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return inverter
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(
|
async def async_unload_entry(
|
||||||
hass: HomeAssistant, config_entry: GoodweConfigEntry
|
hass: HomeAssistant, config_entry: GoodweConfigEntry
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -88,31 +76,3 @@ async def async_unload_entry(
|
|||||||
async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None:
|
async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None:
|
||||||
"""Handle options update."""
|
"""Handle options update."""
|
||||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
async def async_migrate_entry(
|
|
||||||
hass: HomeAssistant, config_entry: GoodweConfigEntry
|
|
||||||
) -> bool:
|
|
||||||
"""Migrate old config entries."""
|
|
||||||
|
|
||||||
if config_entry.version > 2:
|
|
||||||
# This means the user has downgraded from a future version
|
|
||||||
return False
|
|
||||||
|
|
||||||
if config_entry.version == 1:
|
|
||||||
# Update from version 1 to version 2 adding the PROTOCOL to the config entry
|
|
||||||
host = config_entry.data[CONF_HOST]
|
|
||||||
try:
|
|
||||||
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(
|
|
||||||
host=host
|
|
||||||
)
|
|
||||||
except InverterError as err:
|
|
||||||
raise ConfigEntryNotReady from err
|
|
||||||
new_data = {
|
|
||||||
CONF_HOST: host,
|
|
||||||
CONF_PORT: port,
|
|
||||||
CONF_MODEL_FAMILY: type(inverter).__name__,
|
|
||||||
}
|
|
||||||
hass.config_entries.async_update_entry(config_entry, data=new_data, version=2)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from goodwe import Inverter, InverterError, connect
|
from goodwe import InverterError, connect
|
||||||
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
|
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
from homeassistant.const import CONF_HOST
|
||||||
|
|
||||||
from .const import CONF_MODEL_FAMILY, DEFAULT_NAME, DOMAIN
|
from .const import CONF_MODEL_FAMILY, DEFAULT_NAME, DOMAIN
|
||||||
|
|
||||||
@@ -26,15 +26,9 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a Goodwe config flow."""
|
"""Handle a Goodwe config flow."""
|
||||||
|
|
||||||
MINOR_VERSION = 2
|
VERSION = 1
|
||||||
|
|
||||||
async def async_handle_successful_connection(
|
async def _handle_successful_connection(self, inverter, host):
|
||||||
self,
|
|
||||||
inverter: Inverter,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle a successful connection storing it's values on the entry data."""
|
|
||||||
await self.async_set_unique_id(inverter.serial_number)
|
await self.async_set_unique_id(inverter.serial_number)
|
||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
@@ -42,7 +36,6 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
title=DEFAULT_NAME,
|
title=DEFAULT_NAME,
|
||||||
data={
|
data={
|
||||||
CONF_HOST: host,
|
CONF_HOST: host,
|
||||||
CONF_PORT: port,
|
|
||||||
CONF_MODEL_FAMILY: type(inverter).__name__,
|
CONF_MODEL_FAMILY: type(inverter).__name__,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -55,26 +48,19 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
host = user_input[CONF_HOST]
|
host = user_input[CONF_HOST]
|
||||||
try:
|
try:
|
||||||
inverter, port = await self.async_detect_inverter_port(host=host)
|
inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10)
|
||||||
except InverterError:
|
except InverterError:
|
||||||
errors[CONF_HOST] = "connection_error"
|
try:
|
||||||
|
inverter = await connect(
|
||||||
|
host=host, port=GOODWE_TCP_PORT, retries=10
|
||||||
|
)
|
||||||
|
except InverterError:
|
||||||
|
errors[CONF_HOST] = "connection_error"
|
||||||
|
else:
|
||||||
|
return await self._handle_successful_connection(inverter, host)
|
||||||
else:
|
else:
|
||||||
return await self.async_handle_successful_connection(
|
return await self._handle_successful_connection(inverter, host)
|
||||||
inverter, host, port
|
|
||||||
)
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
|
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def async_detect_inverter_port(
|
|
||||||
host: str,
|
|
||||||
) -> tuple[Inverter, int]:
|
|
||||||
"""Detects the port of the Inverter."""
|
|
||||||
port = GOODWE_UDP_PORT
|
|
||||||
try:
|
|
||||||
inverter = await connect(host=host, port=port, retries=10)
|
|
||||||
except InverterError:
|
|
||||||
port = GOODWE_TCP_PORT
|
|
||||||
inverter = await connect(host=host, port=port, retries=10)
|
|
||||||
return inverter, port
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
|
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"quality_scale": "gold",
|
|
||||||
"requirements": ["gassist-text==0.0.14"],
|
"requirements": ["gassist-text==0.0.14"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
rules:
|
|
||||||
# Bronze
|
|
||||||
action-setup: done
|
|
||||||
appropriate-polling:
|
|
||||||
status: exempt
|
|
||||||
comment: No polling.
|
|
||||||
brands: done
|
|
||||||
common-modules: done
|
|
||||||
config-flow-test-coverage: done
|
|
||||||
config-flow: done
|
|
||||||
dependency-transparency: done
|
|
||||||
docs-actions: done
|
|
||||||
docs-high-level-description: done
|
|
||||||
docs-installation-instructions: done
|
|
||||||
docs-removal-instructions: done
|
|
||||||
entity-event-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities.
|
|
||||||
entity-unique-id:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities.
|
|
||||||
has-entity-name:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities.
|
|
||||||
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: done
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities.
|
|
||||||
integration-owner: done
|
|
||||||
log-when-unavailable:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities.
|
|
||||||
parallel-updates:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities to update.
|
|
||||||
reauthentication-flow: done
|
|
||||||
test-coverage: done
|
|
||||||
|
|
||||||
# Gold
|
|
||||||
devices:
|
|
||||||
status: exempt
|
|
||||||
comment: This integration acts as a service and does not represent physical devices.
|
|
||||||
diagnostics: done
|
|
||||||
discovery-update-info:
|
|
||||||
status: exempt
|
|
||||||
comment: No discovery.
|
|
||||||
discovery:
|
|
||||||
status: exempt
|
|
||||||
comment: This is a cloud service integration that cannot be discovered locally.
|
|
||||||
docs-data-update:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities to update.
|
|
||||||
docs-examples: done
|
|
||||||
docs-known-limitations: done
|
|
||||||
docs-supported-devices: done
|
|
||||||
docs-supported-functions: done
|
|
||||||
docs-troubleshooting: done
|
|
||||||
docs-use-cases: done
|
|
||||||
dynamic-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: No devices.
|
|
||||||
entity-category:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities.
|
|
||||||
entity-device-class:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities.
|
|
||||||
entity-disabled-by-default:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities.
|
|
||||||
entity-translations:
|
|
||||||
status: exempt
|
|
||||||
comment: No entities.
|
|
||||||
exception-translations: done
|
|
||||||
icon-translations: done
|
|
||||||
reconfiguration-flow: done
|
|
||||||
repair-issues:
|
|
||||||
status: exempt
|
|
||||||
comment: No repairs.
|
|
||||||
stale-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: No devices.
|
|
||||||
|
|
||||||
# Platinum
|
|
||||||
async-dependency: todo
|
|
||||||
inject-websession:
|
|
||||||
status: exempt
|
|
||||||
comment: The underlying library uses gRPC, not aiohttp/httpx, for communication.
|
|
||||||
strict-typing: done
|
|
||||||
@@ -56,9 +56,6 @@
|
|||||||
"init": {
|
"init": {
|
||||||
"data": {
|
"data": {
|
||||||
"language_code": "Language code"
|
"language_code": "Language code"
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"language_code": "Language for the Google Assistant SDK requests and responses."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from .const import DOMAIN
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from . import GoogleSheetsConfigEntry
|
from . import GoogleSheetsConfigEntry
|
||||||
|
|
||||||
ADD_CREATED_COLUMN = "add_created_column"
|
|
||||||
DATA = "data"
|
DATA = "data"
|
||||||
DATA_CONFIG_ENTRY = "config_entry"
|
DATA_CONFIG_ENTRY = "config_entry"
|
||||||
ROWS = "rows"
|
ROWS = "rows"
|
||||||
@@ -44,7 +43,6 @@ SHEET_SERVICE_SCHEMA = vol.All(
|
|||||||
{
|
{
|
||||||
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
|
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
|
||||||
vol.Optional(WORKSHEET): cv.string,
|
vol.Optional(WORKSHEET): cv.string,
|
||||||
vol.Optional(ADD_CREATED_COLUMN, default=True): cv.boolean,
|
|
||||||
vol.Required(DATA): vol.Any(cv.ensure_list, [dict]),
|
vol.Required(DATA): vol.Any(cv.ensure_list, [dict]),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -71,11 +69,10 @@ def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
|
|||||||
|
|
||||||
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
|
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
|
||||||
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
|
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
|
||||||
add_created_column = call.data[ADD_CREATED_COLUMN]
|
|
||||||
now = str(datetime.now())
|
now = str(datetime.now())
|
||||||
rows = []
|
rows = []
|
||||||
for d in call.data[DATA]:
|
for d in call.data[DATA]:
|
||||||
row_data = ({"created": now} | d) if add_created_column else d
|
row_data = {"created": now} | d
|
||||||
row = [row_data.get(column, "") for column in columns]
|
row = [row_data.get(column, "") for column in columns]
|
||||||
for key, value in row_data.items():
|
for key, value in row_data.items():
|
||||||
if key not in columns:
|
if key not in columns:
|
||||||
|
|||||||
@@ -9,11 +9,6 @@ append_sheet:
|
|||||||
example: "Sheet1"
|
example: "Sheet1"
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
add_created_column:
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
selector:
|
|
||||||
boolean:
|
|
||||||
data:
|
data:
|
||||||
required: true
|
required: true
|
||||||
example: '{"hello": world, "cool": True, "count": 5}'
|
example: '{"hello": world, "cool": True, "count": 5}'
|
||||||
|
|||||||
@@ -45,10 +45,6 @@
|
|||||||
"append_sheet": {
|
"append_sheet": {
|
||||||
"description": "Appends data to a worksheet in Google Sheets.",
|
"description": "Appends data to a worksheet in Google Sheets.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"add_created_column": {
|
|
||||||
"description": "Add a \"created\" column with the current date-time to the appended data.",
|
|
||||||
"name": "Add created column"
|
|
||||||
},
|
|
||||||
"config_entry": {
|
"config_entry": {
|
||||||
"description": "The sheet to add data to.",
|
"description": "The sheet to add data to.",
|
||||||
"name": "Sheet"
|
"name": "Sheet"
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
|
|||||||
# Due dates are returned always in UTC so we only need to
|
# Due dates are returned always in UTC so we only need to
|
||||||
# parse the date portion which will be interpreted as a a local date.
|
# parse the date portion which will be interpreted as a a local date.
|
||||||
due = datetime.fromisoformat(due_str).date()
|
due = datetime.fromisoformat(due_str).date()
|
||||||
completed: datetime | None = None
|
|
||||||
if (completed_str := item.get("completed")) is not None:
|
|
||||||
completed = datetime.fromisoformat(completed_str)
|
|
||||||
return TodoItem(
|
return TodoItem(
|
||||||
summary=item["title"],
|
summary=item["title"],
|
||||||
uid=item["id"],
|
uid=item["id"],
|
||||||
@@ -64,7 +61,6 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
|
|||||||
TodoItemStatus.NEEDS_ACTION,
|
TodoItemStatus.NEEDS_ACTION,
|
||||||
),
|
),
|
||||||
due=due,
|
due=due,
|
||||||
completed=completed,
|
|
||||||
description=item.get("notes"),
|
description=item.get("notes"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,7 @@ SENSOR_DESCRIPTIONS = [
|
|||||||
key="duration",
|
key="duration",
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
device_class=SensorDeviceClass.DURATION,
|
device_class=SensorDeviceClass.DURATION,
|
||||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||||
suggested_unit_of_measurement=UnitOfTime.MINUTES,
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -175,7 +174,7 @@ class GoogleTravelTimeSensor(SensorEntity):
|
|||||||
if self._route is None:
|
if self._route is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self._route.duration.seconds
|
return round(self._route.duration.seconds / 60)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
"""The Google Weather integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from google_weather_api import GoogleWeatherApi
|
|
||||||
|
|
||||||
from homeassistant.const import CONF_API_KEY, Platform
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
|
|
||||||
from .const import CONF_REFERRER
|
|
||||||
from .coordinator import (
|
|
||||||
GoogleWeatherConfigEntry,
|
|
||||||
GoogleWeatherCurrentConditionsCoordinator,
|
|
||||||
GoogleWeatherDailyForecastCoordinator,
|
|
||||||
GoogleWeatherHourlyForecastCoordinator,
|
|
||||||
GoogleWeatherRuntimeData,
|
|
||||||
GoogleWeatherSubEntryRuntimeData,
|
|
||||||
)
|
|
||||||
|
|
||||||
_PLATFORMS: list[Platform] = [Platform.WEATHER]
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
|
|
||||||
) -> bool:
|
|
||||||
"""Set up Google Weather from a config entry."""
|
|
||||||
|
|
||||||
api = GoogleWeatherApi(
|
|
||||||
session=async_get_clientsession(hass),
|
|
||||||
api_key=entry.data[CONF_API_KEY],
|
|
||||||
referrer=entry.data.get(CONF_REFERRER),
|
|
||||||
language_code=hass.config.language,
|
|
||||||
)
|
|
||||||
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {}
|
|
||||||
for subentry in entry.subentries.values():
|
|
||||||
subentry_runtime_data = GoogleWeatherSubEntryRuntimeData(
|
|
||||||
coordinator_observation=GoogleWeatherCurrentConditionsCoordinator(
|
|
||||||
hass, entry, subentry, api
|
|
||||||
),
|
|
||||||
coordinator_daily_forecast=GoogleWeatherDailyForecastCoordinator(
|
|
||||||
hass, entry, subentry, api
|
|
||||||
),
|
|
||||||
coordinator_hourly_forecast=GoogleWeatherHourlyForecastCoordinator(
|
|
||||||
hass, entry, subentry, api
|
|
||||||
),
|
|
||||||
)
|
|
||||||
subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data
|
|
||||||
tasks = [
|
|
||||||
coro
|
|
||||||
for subentry_runtime_data in subentries_runtime_data.values()
|
|
||||||
for coro in (
|
|
||||||
subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(),
|
|
||||||
subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(),
|
|
||||||
subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
entry.runtime_data = GoogleWeatherRuntimeData(
|
|
||||||
api=api,
|
|
||||||
subentries_runtime_data=subentries_runtime_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
|
||||||
|
|
||||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(
|
|
||||||
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
|
|
||||||
) -> bool:
|
|
||||||
"""Unload a config entry."""
|
|
||||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_update_options(
|
|
||||||
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
|
|
||||||
) -> None:
|
|
||||||
"""Update options."""
|
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
"""Config flow for the Google Weather integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.config_entries import (
|
|
||||||
ConfigEntry,
|
|
||||||
ConfigEntryState,
|
|
||||||
ConfigFlow,
|
|
||||||
ConfigFlowResult,
|
|
||||||
ConfigSubentryFlow,
|
|
||||||
SubentryFlowResult,
|
|
||||||
)
|
|
||||||
from homeassistant.const import (
|
|
||||||
CONF_API_KEY,
|
|
||||||
CONF_LATITUDE,
|
|
||||||
CONF_LOCATION,
|
|
||||||
CONF_LONGITUDE,
|
|
||||||
CONF_NAME,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
from homeassistant.data_entry_flow import section
|
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
||||||
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
|
|
||||||
|
|
||||||
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_API_KEY): str,
|
|
||||||
vol.Optional(SECTION_API_KEY_OPTIONS): section(
|
|
||||||
vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_input(
|
|
||||||
user_input: dict[str, Any],
|
|
||||||
api: GoogleWeatherApi,
|
|
||||||
errors: dict[str, str],
|
|
||||||
description_placeholders: dict[str, str],
|
|
||||||
) -> bool:
|
|
||||||
try:
|
|
||||||
await api.async_get_current_conditions(
|
|
||||||
latitude=user_input[CONF_LOCATION][CONF_LATITUDE],
|
|
||||||
longitude=user_input[CONF_LOCATION][CONF_LONGITUDE],
|
|
||||||
)
|
|
||||||
except GoogleWeatherApiError as err:
|
|
||||||
errors["base"] = "cannot_connect"
|
|
||||||
description_placeholders["error_message"] = str(err)
|
|
||||||
except Exception:
|
|
||||||
_LOGGER.exception("Unexpected exception")
|
|
||||||
errors["base"] = "unknown"
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
|
|
||||||
"""Return the schema for a location with default values from the hass config."""
|
|
||||||
return vol.Schema(
|
|
||||||
{
|
|
||||||
vol.Required(CONF_NAME, default=hass.config.location_name): str,
|
|
||||||
vol.Required(
|
|
||||||
CONF_LOCATION,
|
|
||||||
default={
|
|
||||||
CONF_LATITUDE: hass.config.latitude,
|
|
||||||
CONF_LONGITUDE: hass.config.longitude,
|
|
||||||
},
|
|
||||||
): LocationSelector(LocationSelectorConfig(radius=False)),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_location_already_configured(
|
|
||||||
hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4
|
|
||||||
) -> bool:
|
|
||||||
"""Check if the location is already configured."""
|
|
||||||
for entry in hass.config_entries.async_entries(DOMAIN):
|
|
||||||
for subentry in entry.subentries.values():
|
|
||||||
# A more accurate way is to use the haversine formula, but for simplicity
|
|
||||||
# we use a simple distance check. The epsilon value is small anyway.
|
|
||||||
# This is mostly to capture cases where the user has slightly moved the location pin.
|
|
||||||
if (
|
|
||||||
abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon
|
|
||||||
and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE])
|
|
||||||
<= epsilon
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
|
|
||||||
"""Handle a config flow for Google Weather."""
|
|
||||||
|
|
||||||
VERSION = 1
|
|
||||||
|
|
||||||
async def async_step_user(
|
|
||||||
self, user_input: dict[str, Any] | None = None
|
|
||||||
) -> ConfigFlowResult:
|
|
||||||
"""Handle the initial step."""
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
description_placeholders: dict[str, str] = {
|
|
||||||
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
|
|
||||||
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
|
|
||||||
}
|
|
||||||
if user_input is not None:
|
|
||||||
api_key = user_input[CONF_API_KEY]
|
|
||||||
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
|
|
||||||
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
|
||||||
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
|
|
||||||
return self.async_abort(reason="already_configured")
|
|
||||||
api = GoogleWeatherApi(
|
|
||||||
session=async_get_clientsession(self.hass),
|
|
||||||
api_key=api_key,
|
|
||||||
referrer=referrer,
|
|
||||||
language_code=self.hass.config.language,
|
|
||||||
)
|
|
||||||
if await _validate_input(user_input, api, errors, description_placeholders):
|
|
||||||
return self.async_create_entry(
|
|
||||||
title="Google Weather",
|
|
||||||
data={
|
|
||||||
CONF_API_KEY: api_key,
|
|
||||||
CONF_REFERRER: referrer,
|
|
||||||
},
|
|
||||||
subentries=[
|
|
||||||
{
|
|
||||||
"subentry_type": "location",
|
|
||||||
"data": user_input[CONF_LOCATION],
|
|
||||||
"title": user_input[CONF_NAME],
|
|
||||||
"unique_id": None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
user_input = {}
|
|
||||||
schema = STEP_USER_DATA_SCHEMA.schema.copy()
|
|
||||||
schema.update(_get_location_schema(self.hass).schema)
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=self.add_suggested_values_to_schema(
|
|
||||||
vol.Schema(schema), user_input
|
|
||||||
),
|
|
||||||
errors=errors,
|
|
||||||
description_placeholders=description_placeholders,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@callback
|
|
||||||
def async_get_supported_subentry_types(
|
|
||||||
cls, config_entry: ConfigEntry
|
|
||||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
|
||||||
"""Return subentries supported by this integration."""
|
|
||||||
return {"location": LocationSubentryFlowHandler}
|
|
||||||
|
|
||||||
|
|
||||||
class LocationSubentryFlowHandler(ConfigSubentryFlow):
|
|
||||||
"""Handle a subentry flow for location."""
|
|
||||||
|
|
||||||
async def async_step_location(
|
|
||||||
self,
|
|
||||||
user_input: dict[str, Any] | None = None,
|
|
||||||
) -> SubentryFlowResult:
|
|
||||||
"""Handle the location step."""
|
|
||||||
if self._get_entry().state != ConfigEntryState.LOADED:
|
|
||||||
return self.async_abort(reason="entry_not_loaded")
|
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
description_placeholders: dict[str, str] = {}
|
|
||||||
if user_input is not None:
|
|
||||||
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
|
|
||||||
return self.async_abort(reason="already_configured")
|
|
||||||
api: GoogleWeatherApi = self._get_entry().runtime_data.api
|
|
||||||
if await _validate_input(user_input, api, errors, description_placeholders):
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=user_input[CONF_NAME],
|
|
||||||
data=user_input[CONF_LOCATION],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
user_input = {}
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="location",
|
|
||||||
data_schema=self.add_suggested_values_to_schema(
|
|
||||||
_get_location_schema(self.hass), user_input
|
|
||||||
),
|
|
||||||
errors=errors,
|
|
||||||
description_placeholders=description_placeholders,
|
|
||||||
)
|
|
||||||
|
|
||||||
async_step_user = async_step_location
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Constants for the Google Weather integration."""
|
|
||||||
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
DOMAIN = "google_weather"
|
|
||||||
|
|
||||||
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
|
|
||||||
CONF_REFERRER: Final = "referrer"
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
"""The Google Weather coordinator."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
|
||||||
from typing import TypeVar
|
|
||||||
|
|
||||||
from google_weather_api import (
|
|
||||||
CurrentConditionsResponse,
|
|
||||||
DailyForecastResponse,
|
|
||||||
GoogleWeatherApi,
|
|
||||||
GoogleWeatherApiError,
|
|
||||||
HourlyForecastResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
|
||||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.update_coordinator import (
|
|
||||||
TimestampDataUpdateCoordinator,
|
|
||||||
UpdateFailed,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
T = TypeVar(
|
|
||||||
"T",
|
|
||||||
bound=(
|
|
||||||
CurrentConditionsResponse
|
|
||||||
| DailyForecastResponse
|
|
||||||
| HourlyForecastResponse
|
|
||||||
| None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GoogleWeatherSubEntryRuntimeData:
|
|
||||||
"""Runtime data for a Google Weather sub-entry."""
|
|
||||||
|
|
||||||
coordinator_observation: GoogleWeatherCurrentConditionsCoordinator
|
|
||||||
coordinator_daily_forecast: GoogleWeatherDailyForecastCoordinator
|
|
||||||
coordinator_hourly_forecast: GoogleWeatherHourlyForecastCoordinator
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GoogleWeatherRuntimeData:
|
|
||||||
"""Runtime data for the Google Weather integration."""
|
|
||||||
|
|
||||||
api: GoogleWeatherApi
|
|
||||||
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData]
|
|
||||||
|
|
||||||
|
|
||||||
type GoogleWeatherConfigEntry = ConfigEntry[GoogleWeatherRuntimeData]
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
|
|
||||||
"""Base class for Google Weather coordinators."""
|
|
||||||
|
|
||||||
config_entry: GoogleWeatherConfigEntry
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: GoogleWeatherConfigEntry,
|
|
||||||
subentry: ConfigSubentry,
|
|
||||||
data_type_name: str,
|
|
||||||
update_interval: timedelta,
|
|
||||||
api_method: Callable[..., Awaitable[T]],
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the data updater."""
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
config_entry=config_entry,
|
|
||||||
name=f"Google Weather {data_type_name} coordinator for {subentry.title}",
|
|
||||||
update_interval=update_interval,
|
|
||||||
)
|
|
||||||
self.subentry = subentry
|
|
||||||
self._data_type_name = data_type_name
|
|
||||||
self._api_method = api_method
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> T:
|
|
||||||
"""Fetch data from API and handle errors."""
|
|
||||||
try:
|
|
||||||
return await self._api_method(
|
|
||||||
self.subentry.data[CONF_LATITUDE],
|
|
||||||
self.subentry.data[CONF_LONGITUDE],
|
|
||||||
)
|
|
||||||
except GoogleWeatherApiError as err:
|
|
||||||
_LOGGER.error(
|
|
||||||
"Error fetching %s for %s: %s",
|
|
||||||
self._data_type_name,
|
|
||||||
self.subentry.title,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleWeatherCurrentConditionsCoordinator(
|
|
||||||
GoogleWeatherBaseCoordinator[CurrentConditionsResponse]
|
|
||||||
):
|
|
||||||
"""Handle fetching current weather conditions."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: GoogleWeatherConfigEntry,
|
|
||||||
subentry: ConfigSubentry,
|
|
||||||
api: GoogleWeatherApi,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the data updater."""
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
config_entry,
|
|
||||||
subentry,
|
|
||||||
"current weather conditions",
|
|
||||||
timedelta(minutes=15),
|
|
||||||
api.async_get_current_conditions,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleWeatherDailyForecastCoordinator(
|
|
||||||
GoogleWeatherBaseCoordinator[DailyForecastResponse]
|
|
||||||
):
|
|
||||||
"""Handle fetching daily weather forecast."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: GoogleWeatherConfigEntry,
|
|
||||||
subentry: ConfigSubentry,
|
|
||||||
api: GoogleWeatherApi,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the data updater."""
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
config_entry,
|
|
||||||
subentry,
|
|
||||||
"daily weather forecast",
|
|
||||||
timedelta(hours=1),
|
|
||||||
api.async_get_daily_forecast,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleWeatherHourlyForecastCoordinator(
|
|
||||||
GoogleWeatherBaseCoordinator[HourlyForecastResponse]
|
|
||||||
):
|
|
||||||
"""Handle fetching hourly weather forecast."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config_entry: GoogleWeatherConfigEntry,
|
|
||||||
subentry: ConfigSubentry,
|
|
||||||
api: GoogleWeatherApi,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the data updater."""
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
config_entry,
|
|
||||||
subentry,
|
|
||||||
"hourly weather forecast",
|
|
||||||
timedelta(hours=1),
|
|
||||||
api.async_get_hourly_forecast,
|
|
||||||
)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Base entity for Google Weather."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigSubentry
|
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
|
||||||
from homeassistant.helpers.entity import Entity
|
|
||||||
|
|
||||||
from .const import DOMAIN
|
|
||||||
from .coordinator import GoogleWeatherConfigEntry
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleWeatherBaseEntity(Entity):
|
|
||||||
"""Base entity for all Google Weather entities."""
|
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
|
|
||||||
) -> None:
|
|
||||||
"""Initialize base entity."""
|
|
||||||
self._attr_unique_id = subentry.subentry_id
|
|
||||||
self._attr_device_info = DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
|
||||||
name=subentry.title,
|
|
||||||
manufacturer="Google",
|
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
|
||||||
)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "google_weather",
|
|
||||||
"name": "Google Weather",
|
|
||||||
"codeowners": ["@tronikos"],
|
|
||||||
"config_flow": true,
|
|
||||||
"documentation": "https://www.home-assistant.io/integrations/google_weather",
|
|
||||||
"integration_type": "service",
|
|
||||||
"iot_class": "cloud_polling",
|
|
||||||
"loggers": ["google_weather_api"],
|
|
||||||
"quality_scale": "bronze",
|
|
||||||
"requirements": ["python-google-weather-api==0.0.4"]
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
rules:
|
|
||||||
# Bronze
|
|
||||||
action-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: No actions.
|
|
||||||
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 actions.
|
|
||||||
docs-high-level-description: done
|
|
||||||
docs-installation-instructions: done
|
|
||||||
docs-removal-instructions: done
|
|
||||||
entity-event-setup:
|
|
||||||
status: exempt
|
|
||||||
comment: No events subscribed.
|
|
||||||
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:
|
|
||||||
status: exempt
|
|
||||||
comment: No actions.
|
|
||||||
config-entry-unloading: done
|
|
||||||
docs-configuration-parameters:
|
|
||||||
status: exempt
|
|
||||||
comment: No configuration options.
|
|
||||||
docs-installation-parameters: done
|
|
||||||
entity-unavailable: done
|
|
||||||
integration-owner: done
|
|
||||||
log-when-unavailable: done
|
|
||||||
parallel-updates: done
|
|
||||||
reauthentication-flow: todo
|
|
||||||
test-coverage: done
|
|
||||||
|
|
||||||
# Gold
|
|
||||||
devices: done
|
|
||||||
diagnostics: todo
|
|
||||||
discovery-update-info:
|
|
||||||
status: exempt
|
|
||||||
comment: No discovery.
|
|
||||||
discovery:
|
|
||||||
status: exempt
|
|
||||||
comment: No discovery.
|
|
||||||
docs-data-update: done
|
|
||||||
docs-examples: done
|
|
||||||
docs-known-limitations: done
|
|
||||||
docs-supported-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: No physical devices.
|
|
||||||
docs-supported-functions: done
|
|
||||||
docs-troubleshooting: done
|
|
||||||
docs-use-cases: done
|
|
||||||
dynamic-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: N/A
|
|
||||||
entity-category: done
|
|
||||||
entity-device-class: done
|
|
||||||
entity-disabled-by-default: done
|
|
||||||
entity-translations: done
|
|
||||||
exception-translations: todo
|
|
||||||
icon-translations: done
|
|
||||||
reconfiguration-flow: todo
|
|
||||||
repair-issues:
|
|
||||||
status: exempt
|
|
||||||
comment: No repairs.
|
|
||||||
stale-devices:
|
|
||||||
status: exempt
|
|
||||||
comment: N/A
|
|
||||||
|
|
||||||
# Platinum
|
|
||||||
async-dependency: done
|
|
||||||
inject-websession: done
|
|
||||||
strict-typing: done
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
{
|
|
||||||
"config": {
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}",
|
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
|
||||||
},
|
|
||||||
"step": {
|
|
||||||
"user": {
|
|
||||||
"data": {
|
|
||||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
|
||||||
"location": "[%key:common::config_flow::data::location%]",
|
|
||||||
"name": "[%key:common::config_flow::data::name%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"api_key": "A unique alphanumeric string that associates your Google billing account with Google Weather API",
|
|
||||||
"location": "Location coordinates",
|
|
||||||
"name": "Location name"
|
|
||||||
},
|
|
||||||
"description": "Get your API key from [here]({api_key_url}).",
|
|
||||||
"sections": {
|
|
||||||
"api_key_options": {
|
|
||||||
"data": {
|
|
||||||
"referrer": "HTTP referrer"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})"
|
|
||||||
},
|
|
||||||
"name": "Optional API key options"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"config_subentries": {
|
|
||||||
"location": {
|
|
||||||
"abort": {
|
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
|
|
||||||
"entry_not_loaded": "Cannot add things while the configuration is disabled."
|
|
||||||
},
|
|
||||||
"entry_type": "Location",
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "[%key:component::google_weather::config::error::cannot_connect%]",
|
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
|
||||||
},
|
|
||||||
"initiate_flow": {
|
|
||||||
"user": "Add location"
|
|
||||||
},
|
|
||||||
"step": {
|
|
||||||
"location": {
|
|
||||||
"data": {
|
|
||||||
"location": "[%key:common::config_flow::data::location%]",
|
|
||||||
"name": "[%key:common::config_flow::data::name%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"location": "[%key:component::google_weather::config::step::user::data_description::location%]",
|
|
||||||
"name": "[%key:component::google_weather::config::step::user::data_description::name%]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
"""Weather entity."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from google_weather_api import (
|
|
||||||
DailyForecastResponse,
|
|
||||||
HourlyForecastResponse,
|
|
||||||
WeatherCondition,
|
|
||||||
)
|
|
||||||
|
|
||||||
from homeassistant.components.weather import (
|
|
||||||
ATTR_CONDITION_CLEAR_NIGHT,
|
|
||||||
ATTR_CONDITION_CLOUDY,
|
|
||||||
ATTR_CONDITION_HAIL,
|
|
||||||
ATTR_CONDITION_LIGHTNING_RAINY,
|
|
||||||
ATTR_CONDITION_PARTLYCLOUDY,
|
|
||||||
ATTR_CONDITION_POURING,
|
|
||||||
ATTR_CONDITION_RAINY,
|
|
||||||
ATTR_CONDITION_SNOWY,
|
|
||||||
ATTR_CONDITION_SNOWY_RAINY,
|
|
||||||
ATTR_CONDITION_SUNNY,
|
|
||||||
ATTR_CONDITION_WINDY,
|
|
||||||
ATTR_FORECAST_CLOUD_COVERAGE,
|
|
||||||
ATTR_FORECAST_CONDITION,
|
|
||||||
ATTR_FORECAST_HUMIDITY,
|
|
||||||
ATTR_FORECAST_IS_DAYTIME,
|
|
||||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
|
|
||||||
ATTR_FORECAST_NATIVE_DEW_POINT,
|
|
||||||
ATTR_FORECAST_NATIVE_PRECIPITATION,
|
|
||||||
ATTR_FORECAST_NATIVE_PRESSURE,
|
|
||||||
ATTR_FORECAST_NATIVE_TEMP,
|
|
||||||
ATTR_FORECAST_NATIVE_TEMP_LOW,
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_SPEED,
|
|
||||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
|
|
||||||
ATTR_FORECAST_TIME,
|
|
||||||
ATTR_FORECAST_UV_INDEX,
|
|
||||||
ATTR_FORECAST_WIND_BEARING,
|
|
||||||
CoordinatorWeatherEntity,
|
|
||||||
Forecast,
|
|
||||||
WeatherEntityFeature,
|
|
||||||
)
|
|
||||||
from homeassistant.config_entries import ConfigSubentry
|
|
||||||
from homeassistant.const import (
|
|
||||||
UnitOfLength,
|
|
||||||
UnitOfPrecipitationDepth,
|
|
||||||
UnitOfPressure,
|
|
||||||
UnitOfSpeed,
|
|
||||||
UnitOfTemperature,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
|
|
||||||
from .coordinator import (
|
|
||||||
GoogleWeatherConfigEntry,
|
|
||||||
GoogleWeatherCurrentConditionsCoordinator,
|
|
||||||
GoogleWeatherDailyForecastCoordinator,
|
|
||||||
GoogleWeatherHourlyForecastCoordinator,
|
|
||||||
)
|
|
||||||
from .entity import GoogleWeatherBaseEntity
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
|
||||||
|
|
||||||
# Maps https://developers.google.com/maps/documentation/weather/weather-condition-icons
|
|
||||||
# to https://developers.home-assistant.io/docs/core/entity/weather/#recommended-values-for-state-and-condition
|
|
||||||
_CONDITION_MAP: dict[WeatherCondition.Type, str | None] = {
|
|
||||||
WeatherCondition.Type.TYPE_UNSPECIFIED: None,
|
|
||||||
WeatherCondition.Type.CLEAR: ATTR_CONDITION_SUNNY,
|
|
||||||
WeatherCondition.Type.MOSTLY_CLEAR: ATTR_CONDITION_PARTLYCLOUDY,
|
|
||||||
WeatherCondition.Type.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY,
|
|
||||||
WeatherCondition.Type.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY,
|
|
||||||
WeatherCondition.Type.CLOUDY: ATTR_CONDITION_CLOUDY,
|
|
||||||
WeatherCondition.Type.WINDY: ATTR_CONDITION_WINDY,
|
|
||||||
WeatherCondition.Type.WIND_AND_RAIN: ATTR_CONDITION_RAINY,
|
|
||||||
WeatherCondition.Type.LIGHT_RAIN_SHOWERS: ATTR_CONDITION_RAINY,
|
|
||||||
WeatherCondition.Type.CHANCE_OF_SHOWERS: ATTR_CONDITION_RAINY,
|
|
||||||
WeatherCondition.Type.SCATTERED_SHOWERS: ATTR_CONDITION_RAINY,
|
|
||||||
WeatherCondition.Type.RAIN_SHOWERS: ATTR_CONDITION_RAINY,
|
|
||||||
WeatherCondition.Type.HEAVY_RAIN_SHOWERS: ATTR_CONDITION_POURING,
|
|
||||||
WeatherCondition.Type.LIGHT_TO_MODERATE_RAIN: ATTR_CONDITION_RAINY,
|
|
||||||
WeatherCondition.Type.MODERATE_TO_HEAVY_RAIN: ATTR_CONDITION_POURING,
|
|
||||||
WeatherCondition.Type.RAIN: ATTR_CONDITION_RAINY,
|
|
||||||
WeatherCondition.Type.LIGHT_RAIN: ATTR_CONDITION_RAINY,
|
|
||||||
WeatherCondition.Type.HEAVY_RAIN: ATTR_CONDITION_POURING,
|
|
||||||
WeatherCondition.Type.RAIN_PERIODICALLY_HEAVY: ATTR_CONDITION_POURING,
|
|
||||||
WeatherCondition.Type.LIGHT_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.CHANCE_OF_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.SCATTERED_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.HEAVY_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.LIGHT_TO_MODERATE_SNOW: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.MODERATE_TO_HEAVY_SNOW: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.SNOW: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.LIGHT_SNOW: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.HEAVY_SNOW: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.SNOWSTORM: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.SNOW_PERIODICALLY_HEAVY: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.HEAVY_SNOW_STORM: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.BLOWING_SNOW: ATTR_CONDITION_SNOWY,
|
|
||||||
WeatherCondition.Type.RAIN_AND_SNOW: ATTR_CONDITION_SNOWY_RAINY,
|
|
||||||
WeatherCondition.Type.HAIL: ATTR_CONDITION_HAIL,
|
|
||||||
WeatherCondition.Type.HAIL_SHOWERS: ATTR_CONDITION_HAIL,
|
|
||||||
WeatherCondition.Type.THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
|
|
||||||
WeatherCondition.Type.THUNDERSHOWER: ATTR_CONDITION_LIGHTNING_RAINY,
|
|
||||||
WeatherCondition.Type.LIGHT_THUNDERSTORM_RAIN: ATTR_CONDITION_LIGHTNING_RAINY,
|
|
||||||
WeatherCondition.Type.SCATTERED_THUNDERSTORMS: ATTR_CONDITION_LIGHTNING_RAINY,
|
|
||||||
WeatherCondition.Type.HEAVY_THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_condition(
|
|
||||||
api_condition: WeatherCondition.Type, is_daytime: bool
|
|
||||||
) -> str | None:
|
|
||||||
"""Map Google Weather condition to Home Assistant condition."""
|
|
||||||
cond = _CONDITION_MAP[api_condition]
|
|
||||||
if cond == ATTR_CONDITION_SUNNY and not is_daytime:
|
|
||||||
return ATTR_CONDITION_CLEAR_NIGHT
|
|
||||||
return cond
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: GoogleWeatherConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Add a weather entity from a config_entry."""
|
|
||||||
for subentry in entry.subentries.values():
|
|
||||||
async_add_entities(
|
|
||||||
[GoogleWeatherEntity(entry, subentry)],
|
|
||||||
config_subentry_id=subentry.subentry_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleWeatherEntity(
|
|
||||||
CoordinatorWeatherEntity[
|
|
||||||
GoogleWeatherCurrentConditionsCoordinator,
|
|
||||||
GoogleWeatherDailyForecastCoordinator,
|
|
||||||
GoogleWeatherHourlyForecastCoordinator,
|
|
||||||
GoogleWeatherDailyForecastCoordinator,
|
|
||||||
],
|
|
||||||
GoogleWeatherBaseEntity,
|
|
||||||
):
|
|
||||||
"""Representation of a Google Weather entity."""
|
|
||||||
|
|
||||||
_attr_attribution = "Data from Google Weather"
|
|
||||||
|
|
||||||
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
|
|
||||||
_attr_native_pressure_unit = UnitOfPressure.MBAR
|
|
||||||
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
|
|
||||||
_attr_native_visibility_unit = UnitOfLength.KILOMETERS
|
|
||||||
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
|
|
||||||
|
|
||||||
_attr_name = None
|
|
||||||
|
|
||||||
_attr_supported_features = (
|
|
||||||
WeatherEntityFeature.FORECAST_DAILY
|
|
||||||
| WeatherEntityFeature.FORECAST_HOURLY
|
|
||||||
| WeatherEntityFeature.FORECAST_TWICE_DAILY
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
entry: GoogleWeatherConfigEntry,
|
|
||||||
subentry: ConfigSubentry,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize the weather entity."""
|
|
||||||
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
|
|
||||||
subentry.subentry_id
|
|
||||||
]
|
|
||||||
super().__init__(
|
|
||||||
observation_coordinator=subentry_runtime_data.coordinator_observation,
|
|
||||||
daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
|
|
||||||
hourly_coordinator=subentry_runtime_data.coordinator_hourly_forecast,
|
|
||||||
twice_daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
|
|
||||||
)
|
|
||||||
GoogleWeatherBaseEntity.__init__(self, entry, subentry)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def condition(self) -> str | None:
|
|
||||||
"""Return the current condition."""
|
|
||||||
return _get_condition(
|
|
||||||
self.coordinator.data.weather_condition.type,
|
|
||||||
self.coordinator.data.is_daytime,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_temperature(self) -> float:
|
|
||||||
"""Return the temperature."""
|
|
||||||
return self.coordinator.data.temperature.degrees
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_apparent_temperature(self) -> float:
|
|
||||||
"""Return the apparent temperature."""
|
|
||||||
return self.coordinator.data.feels_like_temperature.degrees
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_dew_point(self) -> float:
|
|
||||||
"""Return the dew point."""
|
|
||||||
return self.coordinator.data.dew_point.degrees
|
|
||||||
|
|
||||||
@property
|
|
||||||
def humidity(self) -> int:
|
|
||||||
"""Return the humidity."""
|
|
||||||
return self.coordinator.data.relative_humidity
|
|
||||||
|
|
||||||
@property
|
|
||||||
def uv_index(self) -> float:
|
|
||||||
"""Return the UV index."""
|
|
||||||
return float(self.coordinator.data.uv_index)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_pressure(self) -> float:
|
|
||||||
"""Return the pressure."""
|
|
||||||
return self.coordinator.data.air_pressure.mean_sea_level_millibars
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_wind_gust_speed(self) -> float:
|
|
||||||
"""Return the wind gust speed."""
|
|
||||||
return self.coordinator.data.wind.gust.value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_wind_speed(self) -> float:
|
|
||||||
"""Return the wind speed."""
|
|
||||||
return self.coordinator.data.wind.speed.value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wind_bearing(self) -> int:
|
|
||||||
"""Return the wind bearing."""
|
|
||||||
return self.coordinator.data.wind.direction.degrees
|
|
||||||
|
|
||||||
@property
|
|
||||||
def native_visibility(self) -> float:
|
|
||||||
"""Return the visibility."""
|
|
||||||
return self.coordinator.data.visibility.distance
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cloud_coverage(self) -> float:
|
|
||||||
"""Return the Cloud coverage in %."""
|
|
||||||
return float(self.coordinator.data.cloud_cover)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_forecast_daily(self) -> list[Forecast] | None:
|
|
||||||
"""Return the daily forecast in native units."""
|
|
||||||
coordinator = self.forecast_coordinators["daily"]
|
|
||||||
assert coordinator
|
|
||||||
daily_data = coordinator.data
|
|
||||||
assert isinstance(daily_data, DailyForecastResponse)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
ATTR_FORECAST_CONDITION: _get_condition(
|
|
||||||
item.daytime_forecast.weather_condition.type, is_daytime=True
|
|
||||||
),
|
|
||||||
ATTR_FORECAST_TIME: item.interval.start_time,
|
|
||||||
ATTR_FORECAST_HUMIDITY: item.daytime_forecast.relative_humidity,
|
|
||||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: max(
|
|
||||||
item.daytime_forecast.precipitation.probability.percent,
|
|
||||||
item.nighttime_forecast.precipitation.probability.percent,
|
|
||||||
),
|
|
||||||
ATTR_FORECAST_CLOUD_COVERAGE: item.daytime_forecast.cloud_cover,
|
|
||||||
ATTR_FORECAST_NATIVE_PRECIPITATION: (
|
|
||||||
item.daytime_forecast.precipitation.qpf.quantity
|
|
||||||
+ item.nighttime_forecast.precipitation.qpf.quantity
|
|
||||||
),
|
|
||||||
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
|
|
||||||
ATTR_FORECAST_NATIVE_TEMP_LOW: item.min_temperature.degrees,
|
|
||||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: (
|
|
||||||
item.feels_like_max_temperature.degrees
|
|
||||||
),
|
|
||||||
ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees,
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max(
|
|
||||||
item.daytime_forecast.wind.gust.value,
|
|
||||||
item.nighttime_forecast.wind.gust.value,
|
|
||||||
),
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_SPEED: max(
|
|
||||||
item.daytime_forecast.wind.speed.value,
|
|
||||||
item.nighttime_forecast.wind.speed.value,
|
|
||||||
),
|
|
||||||
ATTR_FORECAST_UV_INDEX: item.daytime_forecast.uv_index,
|
|
||||||
}
|
|
||||||
for item in daily_data.forecast_days
|
|
||||||
]
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_forecast_hourly(self) -> list[Forecast] | None:
|
|
||||||
"""Return the hourly forecast in native units."""
|
|
||||||
coordinator = self.forecast_coordinators["hourly"]
|
|
||||||
assert coordinator
|
|
||||||
hourly_data = coordinator.data
|
|
||||||
assert isinstance(hourly_data, HourlyForecastResponse)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
ATTR_FORECAST_CONDITION: _get_condition(
|
|
||||||
item.weather_condition.type, item.is_daytime
|
|
||||||
),
|
|
||||||
ATTR_FORECAST_TIME: item.interval.start_time,
|
|
||||||
ATTR_FORECAST_HUMIDITY: item.relative_humidity,
|
|
||||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent,
|
|
||||||
ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover,
|
|
||||||
ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity,
|
|
||||||
ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars,
|
|
||||||
ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees,
|
|
||||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees,
|
|
||||||
ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees,
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item.wind.gust.value,
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_SPEED: item.wind.speed.value,
|
|
||||||
ATTR_FORECAST_NATIVE_DEW_POINT: item.dew_point.degrees,
|
|
||||||
ATTR_FORECAST_UV_INDEX: item.uv_index,
|
|
||||||
ATTR_FORECAST_IS_DAYTIME: item.is_daytime,
|
|
||||||
}
|
|
||||||
for item in hourly_data.forecast_hours
|
|
||||||
]
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
|
|
||||||
"""Return the twice daily forecast in native units."""
|
|
||||||
coordinator = self.forecast_coordinators["twice_daily"]
|
|
||||||
assert coordinator
|
|
||||||
daily_data = coordinator.data
|
|
||||||
assert isinstance(daily_data, DailyForecastResponse)
|
|
||||||
forecasts: list[Forecast] = []
|
|
||||||
for item in daily_data.forecast_days:
|
|
||||||
# Process daytime forecast
|
|
||||||
day_forecast = item.daytime_forecast
|
|
||||||
forecasts.append(
|
|
||||||
{
|
|
||||||
ATTR_FORECAST_CONDITION: _get_condition(
|
|
||||||
day_forecast.weather_condition.type, is_daytime=True
|
|
||||||
),
|
|
||||||
ATTR_FORECAST_TIME: day_forecast.interval.start_time,
|
|
||||||
ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity,
|
|
||||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent,
|
|
||||||
ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover,
|
|
||||||
ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity,
|
|
||||||
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
|
|
||||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees,
|
|
||||||
ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees,
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value,
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value,
|
|
||||||
ATTR_FORECAST_UV_INDEX: day_forecast.uv_index,
|
|
||||||
ATTR_FORECAST_IS_DAYTIME: True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process nighttime forecast
|
|
||||||
night_forecast = item.nighttime_forecast
|
|
||||||
forecasts.append(
|
|
||||||
{
|
|
||||||
ATTR_FORECAST_CONDITION: _get_condition(
|
|
||||||
night_forecast.weather_condition.type, is_daytime=False
|
|
||||||
),
|
|
||||||
ATTR_FORECAST_TIME: night_forecast.interval.start_time,
|
|
||||||
ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity,
|
|
||||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent,
|
|
||||||
ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover,
|
|
||||||
ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity,
|
|
||||||
ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees,
|
|
||||||
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees,
|
|
||||||
ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees,
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value,
|
|
||||||
ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value,
|
|
||||||
ATTR_FORECAST_UV_INDEX: night_forecast.uv_index,
|
|
||||||
ATTR_FORECAST_IS_DAYTIME: False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return forecasts
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user