mirror of
https://github.com/home-assistant/core.git
synced 2026-03-20 17:54:51 +01:00
Compare commits
133 Commits
real_token
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdfc2f4845 | ||
|
|
184d834a91 | ||
|
|
0c98bf2676 | ||
|
|
229e1ee26b | ||
|
|
fdd2db6f23 | ||
|
|
2886863000 | ||
|
|
bf4170938c | ||
|
|
6b84815c57 | ||
|
|
01b873f3bc | ||
|
|
66b1728c13 | ||
|
|
d11668b868 | ||
|
|
ed3f70bc3f | ||
|
|
008eb39c3b | ||
|
|
a085d91a0d | ||
|
|
6395a0abd0 | ||
|
|
0de2e689f1 | ||
|
|
21d06fdace | ||
|
|
c8cf13ba19 | ||
|
|
d9a29bd486 | ||
|
|
bd0145cb8d | ||
|
|
d002b48335 | ||
|
|
c66daf13d3 | ||
|
|
1cae0e3cd3 | ||
|
|
de93d1d52a | ||
|
|
c67438c515 | ||
|
|
fa57f72f37 | ||
|
|
29309d1315 | ||
|
|
130e0db742 | ||
|
|
450d46f652 | ||
|
|
625603839c | ||
|
|
fb66d766a8 | ||
|
|
e5f13b4126 | ||
|
|
4a22f2c93e | ||
|
|
a5c48b190a | ||
|
|
5e1a0e2152 | ||
|
|
9a5516bb1d | ||
|
|
b9172cf4a8 | ||
|
|
8e4dc29226 | ||
|
|
b152f2f9a6 | ||
|
|
abca80dc13 | ||
|
|
6869369ab2 | ||
|
|
c2dde06713 | ||
|
|
e455c05721 | ||
|
|
085df1de19 | ||
|
|
91a1237965 | ||
|
|
680a6bc856 | ||
|
|
152912c258 | ||
|
|
40e8a1b11a | ||
|
|
69dc354669 | ||
|
|
bbe1bf14ae | ||
|
|
5470d8f8a7 | ||
|
|
99fe4b10d0 | ||
|
|
886b6b08ac | ||
|
|
6a1e7c1cca | ||
|
|
d17df13055 | ||
|
|
f73502c77a | ||
|
|
2c37a86bc9 | ||
|
|
fa8e976de7 | ||
|
|
877bca28ad | ||
|
|
a57c65f512 | ||
|
|
7140826dbb | ||
|
|
5fea8d69d7 | ||
|
|
98e3b9962e | ||
|
|
afe19147f8 | ||
|
|
0e7c25488c | ||
|
|
412e85203d | ||
|
|
55ec4a95fd | ||
|
|
6ea9e9a161 | ||
|
|
b56e6d1ff7 | ||
|
|
b502cdd15b | ||
|
|
b7ba85192d | ||
|
|
04d45c8ada | ||
|
|
ba0804fefa | ||
|
|
538b817bf1 | ||
|
|
7efa2d3cac | ||
|
|
3f872fd196 | ||
|
|
b00f6593f1 | ||
|
|
a63516ff71 | ||
|
|
55b082edb6 | ||
|
|
b0c3ede4fd | ||
|
|
84bd1cd336 | ||
|
|
25bbfcc595 | ||
|
|
bf05925c8b | ||
|
|
488d9ad75c | ||
|
|
2dfad3d755 | ||
|
|
7e759bf730 | ||
|
|
9678049e72 | ||
|
|
8602ba2679 | ||
|
|
78c3503b7d | ||
|
|
fbb3b81991 | ||
|
|
26eaf510ee | ||
|
|
5c83d16995 | ||
|
|
388b258d6c | ||
|
|
2c9a5c10da | ||
|
|
5a68bafd69 | ||
|
|
33fce89a2b | ||
|
|
1932f61da3 | ||
|
|
5a231b27b9 | ||
|
|
5617e8c7bc | ||
|
|
2b5b0e9d0f | ||
|
|
732f553b48 | ||
|
|
0a53b227ed | ||
|
|
44b73ab7bd | ||
|
|
538061d512 | ||
|
|
e307ceccb5 | ||
|
|
ea7558c0ad | ||
|
|
c4399b5547 | ||
|
|
d989a83d7b | ||
|
|
d04f3530df | ||
|
|
647d957ffe | ||
|
|
a3f3c87b39 | ||
|
|
447b17a2a4 | ||
|
|
eb2b92687c | ||
|
|
6424e3658e | ||
|
|
d1d8754853 | ||
|
|
c4ff7fa676 | ||
|
|
f1fe1d3956 | ||
|
|
fd0d60b787 | ||
|
|
9ddefaaacd | ||
|
|
5c8df048b1 | ||
|
|
d86d85ec56 | ||
|
|
660f12b683 | ||
|
|
b8238c86e6 | ||
|
|
754828188e | ||
|
|
6992a3c72b | ||
|
|
738d4f662a | ||
|
|
7f33ac72ab | ||
|
|
0891d814fa | ||
|
|
ddab50edcc | ||
|
|
c8ce4eb32d | ||
|
|
22aca8b7af | ||
|
|
770864082f | ||
|
|
14545660e2 |
@@ -620,12 +620,14 @@ rules:
|
||||
|
||||
### Config Flow Testing
|
||||
- **100% Coverage Required**: All config flow paths must be tested
|
||||
- **Patch Boundaries**: Only patch library or client methods when testing config flows. Do not patch methods defined in `config_flow.py`; exercise the flow logic end-to-end.
|
||||
- **Test Scenarios**:
|
||||
- All flow initiation methods (user, discovery, import)
|
||||
- Successful configuration paths
|
||||
- Error recovery scenarios
|
||||
- Prevention of duplicate entries
|
||||
- Flow completion after errors
|
||||
- Reauthentication/reconfigure flows
|
||||
|
||||
### Testing
|
||||
- **Integration-specific tests** (recommended):
|
||||
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -16,6 +16,7 @@ Dockerfile.dev linguist-language=Dockerfile
|
||||
CODEOWNERS linguist-generated=true
|
||||
Dockerfile linguist-generated=true
|
||||
homeassistant/generated/*.py linguist-generated=true
|
||||
machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
requirements_all.txt linguist-generated=true
|
||||
|
||||
129
.github/workflows/builder.yml
vendored
129
.github/workflows/builder.yml
vendored
@@ -35,6 +35,7 @@ jobs:
|
||||
channel: ${{ steps.version.outputs.channel }}
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
architectures: ${{ env.ARCHITECTURES }}
|
||||
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -100,7 +101,7 @@ jobs:
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
include:
|
||||
- arch: amd64
|
||||
os: ubuntu-latest
|
||||
os: ubuntu-24.04
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
steps:
|
||||
@@ -181,7 +182,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -195,77 +196,20 @@ jobs:
|
||||
run: |
|
||||
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build variables
|
||||
id: vars
|
||||
shell: bash
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify base image signature
|
||||
env:
|
||||
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
|
||||
"${BASE_IMAGE}"
|
||||
|
||||
- name: Verify cache image signature
|
||||
id: cache
|
||||
continue-on-error: true
|
||||
env:
|
||||
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
|
||||
run: |
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
|
||||
"${CACHE_IMAGE}"
|
||||
|
||||
- name: Build base image
|
||||
id: build
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ steps.vars.outputs.platform }}
|
||||
push: true
|
||||
cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }}
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
BUILD_FROM=${{ steps.vars.outputs.base_image }}
|
||||
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||
labels: |
|
||||
io.hass.arch=${{ matrix.arch }}
|
||||
io.hass.version=${{ needs.init.outputs.version }}
|
||||
org.opencontainers.image.created=${{ steps.vars.outputs.created }}
|
||||
org.opencontainers.image.version=${{ needs.init.outputs.version }}
|
||||
|
||||
- name: Sign image
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
DIGEST: ${{ steps.build.outputs.digest }}
|
||||
run: |
|
||||
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}"
|
||||
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
|
||||
cache-gha: false
|
||||
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
|
||||
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
|
||||
image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
|
||||
image-tags: ${{ needs.init.outputs.version }}
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
|
||||
build_machine:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
@@ -314,35 +258,38 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set build additional args
|
||||
- name: Compute extra tags
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Create general tags
|
||||
if [[ "${VERSION}" =~ d ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
|
||||
echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${VERSION}" =~ b ]]; then
|
||||
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
|
||||
echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
|
||||
echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
- name: Build machine image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
|
||||
with:
|
||||
image: ${{ matrix.arch }}
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--target /data/machine \
|
||||
--cosign \
|
||||
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
cache-gha: false
|
||||
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: machine/
|
||||
cosign-base-identity: "https://github.com/home-assistant/core/.*"
|
||||
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
file: machine/${{ matrix.machine }}
|
||||
image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
|
||||
image-tags: |
|
||||
${{ needs.init.outputs.version }}
|
||||
${{ steps.tags.outputs.extra_tags }}
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
|
||||
publish_ha:
|
||||
name: Publish version files
|
||||
@@ -543,7 +490,7 @@ jobs:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: translations
|
||||
|
||||
|
||||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -978,7 +978,7 @@ jobs:
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: pytest_buckets
|
||||
- name: Compile English translations
|
||||
@@ -1387,7 +1387,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -1558,7 +1558,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: coverage-*
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -1587,7 +1587,7 @@ jobs:
|
||||
&& needs.info.outputs.skip_coverage != 'true' && !cancelled()
|
||||
steps:
|
||||
- name: Download all coverage artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-results-*
|
||||
- name: Upload test results to Codecov
|
||||
|
||||
10
.github/workflows/wheels.yml
vendored
10
.github/workflows/wheels.yml
vendored
@@ -121,12 +121,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
@@ -172,17 +172,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
- name: Download requirements_all_wheels
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ homeassistant.components.calendar.*
|
||||
homeassistant.components.cambridge_audio.*
|
||||
homeassistant.components.camera.*
|
||||
homeassistant.components.canary.*
|
||||
homeassistant.components.casper_glow.*
|
||||
homeassistant.components.cert_expiry.*
|
||||
homeassistant.components.clickatell.*
|
||||
homeassistant.components.clicksend.*
|
||||
@@ -173,6 +174,7 @@ homeassistant.components.dnsip.*
|
||||
homeassistant.components.doorbird.*
|
||||
homeassistant.components.dormakaba_dkey.*
|
||||
homeassistant.components.downloader.*
|
||||
homeassistant.components.dropbox.*
|
||||
homeassistant.components.droplet.*
|
||||
homeassistant.components.dsmr.*
|
||||
homeassistant.components.duckdns.*
|
||||
|
||||
18
CODEOWNERS
generated
18
CODEOWNERS
generated
@@ -273,6 +273,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/cambridge_audio/ @noahhusby
|
||||
/homeassistant/components/camera/ @home-assistant/core
|
||||
/tests/components/camera/ @home-assistant/core
|
||||
/homeassistant/components/casper_glow/ @mikeodr
|
||||
/tests/components/casper_glow/ @mikeodr
|
||||
/homeassistant/components/cast/ @emontnemery
|
||||
/tests/components/cast/ @emontnemery
|
||||
/homeassistant/components/ccm15/ @ocalvo
|
||||
@@ -397,6 +399,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/dremel_3d_printer/ @tkdrob
|
||||
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
|
||||
/homeassistant/components/dropbox/ @bdr99
|
||||
/tests/components/dropbox/ @bdr99
|
||||
/homeassistant/components/droplet/ @sarahseidman
|
||||
/tests/components/droplet/ @sarahseidman
|
||||
/homeassistant/components/dsmr/ @Robbie1221
|
||||
@@ -945,6 +949,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/homeassistant/components/libre_hardware_monitor/ @Sab44
|
||||
/tests/components/libre_hardware_monitor/ @Sab44
|
||||
/homeassistant/components/lichess/ @aryanhasgithub
|
||||
/tests/components/lichess/ @aryanhasgithub
|
||||
/homeassistant/components/lidarr/ @tkdrob
|
||||
/tests/components/lidarr/ @tkdrob
|
||||
/homeassistant/components/liebherr/ @mettolen
|
||||
@@ -1561,8 +1567,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/sma/ @kellerza @rklomp @erwindouna
|
||||
/homeassistant/components/smappee/ @bsmappee
|
||||
/tests/components/smappee/ @bsmappee
|
||||
/homeassistant/components/smarla/ @explicatis @rlint-explicatis
|
||||
/tests/components/smarla/ @explicatis @rlint-explicatis
|
||||
/homeassistant/components/smarla/ @explicatis @johannes-exp
|
||||
/tests/components/smarla/ @explicatis @johannes-exp
|
||||
/homeassistant/components/smart_meter_texas/ @grahamwetzler
|
||||
/tests/components/smart_meter_texas/ @grahamwetzler
|
||||
/homeassistant/components/smartthings/ @joostlek
|
||||
@@ -1616,8 +1622,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/srp_energy/ @briglx
|
||||
/homeassistant/components/starline/ @anonym-tsk
|
||||
/tests/components/starline/ @anonym-tsk
|
||||
/homeassistant/components/starlink/ @boswelja
|
||||
/tests/components/starlink/ @boswelja
|
||||
/homeassistant/components/statistics/ @ThomDietrich @gjohansson-ST
|
||||
/tests/components/statistics/ @ThomDietrich @gjohansson-ST
|
||||
/homeassistant/components/steam_online/ @tkdrob
|
||||
@@ -1831,8 +1835,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vegehub/ @thulrus
|
||||
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
||||
/tests/components/velbus/ @Cereal2nd @brefra
|
||||
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
|
||||
/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew
|
||||
/homeassistant/components/velux/ @Julius2342 @pawlizio @wollew
|
||||
/tests/components/velux/ @Julius2342 @pawlizio @wollew
|
||||
/homeassistant/components/venstar/ @garbled1 @jhollowe
|
||||
/tests/components/venstar/ @garbled1 @jhollowe
|
||||
/homeassistant/components/versasense/ @imstevenxyz
|
||||
@@ -1915,6 +1919,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/whois/ @frenck
|
||||
/homeassistant/components/wiffi/ @mampfes
|
||||
/tests/components/wiffi/ @mampfes
|
||||
/homeassistant/components/wiim/ @Linkplay2020
|
||||
/tests/components/wiim/ @Linkplay2020
|
||||
/homeassistant/components/wilight/ @leofig-rj
|
||||
/tests/components/wilight/ @leofig-rj
|
||||
/homeassistant/components/window/ @home-assistant/core
|
||||
|
||||
1
Dockerfile
generated
1
Dockerfile
generated
@@ -10,7 +10,6 @@ LABEL \
|
||||
org.opencontainers.image.description="Open-source home automation platform running on Python 3" \
|
||||
org.opencontainers.image.documentation="https://www.home-assistant.io/docs/" \
|
||||
org.opencontainers.image.licenses="Apache-2.0" \
|
||||
org.opencontainers.image.source="https://github.com/home-assistant/core" \
|
||||
org.opencontainers.image.title="Home Assistant" \
|
||||
org.opencontainers.image.url="https://www.home-assistant.io/"
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="timeout",
|
||||
)
|
||||
del self.login_task
|
||||
self.login_task = None
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reauth(
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/actron_air",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["actron-neo-api==0.4.1"]
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
36
homeassistant/components/airq/diagnostics.py
Normal file
36
homeassistant/components/airq/diagnostics.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Diagnostics support for air-Q."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import AirQConfigEntry
|
||||
|
||||
REDACT_CONFIG = {CONF_PASSWORD, CONF_UNIQUE_ID, CONF_IP_ADDRESS, "title"}
|
||||
REDACT_DEVICE_INFO = {"identifiers", "name"}
|
||||
REDACT_COORDINATOR_DATA = {"DeviceID"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: AirQConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"config_entry": async_redact_data(entry.as_dict(), REDACT_CONFIG),
|
||||
"device_info": async_redact_data(
|
||||
dict(coordinator.device_info), REDACT_DEVICE_INFO
|
||||
),
|
||||
"coordinator_data": async_redact_data(
|
||||
coordinator.data, REDACT_COORDINATOR_DATA
|
||||
),
|
||||
"options": {
|
||||
"clip_negative": coordinator.clip_negative,
|
||||
"return_average": coordinator.return_average,
|
||||
},
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["androidtvremote2"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["androidtvremote2==0.2.3"],
|
||||
"requirements": ["androidtvremote2==0.3.1"],
|
||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -123,16 +123,23 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"climate",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"door",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"person",
|
||||
"schedule",
|
||||
"siren",
|
||||
"switch",
|
||||
"vacuum",
|
||||
"window",
|
||||
}
|
||||
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
@@ -159,6 +166,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"remote",
|
||||
"scene",
|
||||
"schedule",
|
||||
"select",
|
||||
"siren",
|
||||
"switch",
|
||||
"text",
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from ..const import LOGGER
|
||||
from ..errors import AuthenticationRequired, CannotConnect
|
||||
@@ -26,7 +26,7 @@ async def get_axis_api(
|
||||
config: Mapping[str, Any],
|
||||
) -> axis.AxisDevice:
|
||||
"""Create a Axis device API."""
|
||||
session = get_async_client(hass, verify_ssl=False)
|
||||
session = async_get_clientsession(hass, verify_ssl=False)
|
||||
|
||||
api = axis.AxisDevice(
|
||||
Configuration(
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==66"],
|
||||
"requirements": ["axis==67"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -246,6 +246,8 @@ def decrypt_backup(
|
||||
except (DecryptError, SecureTarError, tarfile.TarError) as err:
|
||||
LOGGER.warning("Error decrypting backup: %s", err)
|
||||
error = err
|
||||
except Abort:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
|
||||
error = err
|
||||
@@ -332,8 +334,10 @@ def encrypt_backup(
|
||||
except (EncryptError, SecureTarError, tarfile.TarError) as err:
|
||||
LOGGER.warning("Error encrypting backup: %s", err)
|
||||
error = err
|
||||
except Abort:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected error when decrypting backup: %s", err)
|
||||
LOGGER.exception("Unexpected error when encrypting backup: %s", err)
|
||||
error = err
|
||||
else:
|
||||
# Pad the output stream to the requested minimum size
|
||||
|
||||
@@ -32,7 +32,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_PASSKEY, DOMAIN
|
||||
from .const import CONF_PASSKEY, DOMAIN, LOGGER
|
||||
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -52,7 +52,7 @@ class BSBLanData:
|
||||
client: BSBLAN
|
||||
device: Device
|
||||
info: Info
|
||||
static: StaticState
|
||||
static: StaticState | None
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
@@ -82,11 +82,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
|
||||
# the connection by fetching firmware version
|
||||
await bsblan.initialize()
|
||||
|
||||
# Fetch device metadata in parallel for faster startup
|
||||
device, info, static = await asyncio.gather(
|
||||
# Fetch required device metadata in parallel for faster startup
|
||||
device, info = await asyncio.gather(
|
||||
bsblan.device(),
|
||||
bsblan.info(),
|
||||
bsblan.static_values(),
|
||||
)
|
||||
except BSBLANConnectionError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
@@ -111,6 +110,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
|
||||
translation_key="setup_general_error",
|
||||
) from err
|
||||
|
||||
try:
|
||||
static = await bsblan.static_values()
|
||||
except (BSBLANError, TimeoutError) as err:
|
||||
LOGGER.debug(
|
||||
"Static values not available for %s: %s",
|
||||
entry.data[CONF_HOST],
|
||||
err,
|
||||
)
|
||||
static = None
|
||||
|
||||
# Create coordinators with the already-initialized client
|
||||
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
|
||||
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
|
||||
|
||||
@@ -90,10 +90,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
|
||||
|
||||
# Set temperature range if available, otherwise use Home Assistant defaults
|
||||
if data.static.min_temp is not None and data.static.min_temp.value is not None:
|
||||
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
|
||||
if (static := data.static) is not None:
|
||||
if (min_temp := static.min_temp) is not None and min_temp.value is not None:
|
||||
self._attr_min_temp = min_temp.value
|
||||
if (max_temp := static.max_temp) is not None and max_temp.value is not None:
|
||||
self._attr_max_temp = max_temp.value
|
||||
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
||||
|
||||
@property
|
||||
|
||||
@@ -183,90 +183,122 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
existing_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is None:
|
||||
# Preserve existing values as defaults
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=existing_entry.data.get(
|
||||
CONF_PASSKEY, vol.UNDEFINED
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=existing_entry.data.get(
|
||||
CONF_USERNAME, vol.UNDEFINED
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
data_schema=self._build_credentials_schema(existing_entry.data),
|
||||
)
|
||||
|
||||
# Combine existing data with the user's new input for validation.
|
||||
# This correctly handles adding, changing, and clearing credentials.
|
||||
config_data = existing_entry.data.copy()
|
||||
config_data.update(user_input)
|
||||
# Merge existing data with user input for validation
|
||||
validate_data = {**existing_entry.data, **user_input}
|
||||
errors = await self._async_validate_credentials(validate_data)
|
||||
|
||||
self.host = config_data[CONF_HOST]
|
||||
self.port = config_data[CONF_PORT]
|
||||
self.passkey = config_data.get(CONF_PASSKEY)
|
||||
self.username = config_data.get(CONF_USERNAME)
|
||||
self.password = config_data.get(CONF_PASSWORD)
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self._build_credentials_schema(user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry, data_updates=user_input, reason="reauth_successful"
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration flow."""
|
||||
existing_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self._build_connection_schema(existing_entry.data),
|
||||
)
|
||||
|
||||
# Merge existing data with user input for validation
|
||||
validate_data = {**existing_entry.data, **user_input}
|
||||
errors = await self._async_validate_credentials(validate_data)
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self._build_connection_schema(user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# Prevent reconfiguring to a different physical device
|
||||
# it gets the unique ID from the device info when it validates credentials
|
||||
self._abort_if_unique_id_mismatch()
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry,
|
||||
data_updates=user_input,
|
||||
reason="reconfigure_successful",
|
||||
)
|
||||
|
||||
async def _async_validate_credentials(self, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate connection credentials and return errors dict."""
|
||||
self.host = data[CONF_HOST]
|
||||
self.port = data.get(CONF_PORT, DEFAULT_PORT)
|
||||
self.passkey = data.get(CONF_PASSKEY)
|
||||
self.username = data.get(CONF_USERNAME)
|
||||
self.password = data.get(CONF_PASSWORD)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
try:
|
||||
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
|
||||
except BSBLANAuthError:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "invalid_auth"},
|
||||
)
|
||||
errors["base"] = "invalid_auth"
|
||||
except BSBLANError:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors={"base": "cannot_connect"},
|
||||
)
|
||||
errors["base"] = "cannot_connect"
|
||||
return errors
|
||||
|
||||
# Update only the fields that were provided by the user
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry, data_updates=user_input, reason="reauth_successful"
|
||||
@callback
|
||||
def _build_credentials_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
|
||||
"""Build schema for credentials-only forms (reauth)."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
def _build_connection_schema(self, defaults: Mapping[str, Any]) -> vol.Schema:
|
||||
"""Build schema for full connection forms (user and reconfigure)."""
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_HOST,
|
||||
default=defaults.get(CONF_HOST, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PORT,
|
||||
default=defaults.get(CONF_PORT, DEFAULT_PORT),
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_PASSKEY,
|
||||
default=defaults.get(CONF_PASSKEY) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME) or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -274,32 +306,9 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, errors: dict | None = None, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the setup form to the user."""
|
||||
# Preserve user input if provided, otherwise use defaults
|
||||
defaults = user_input or {}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
data_schema=self._build_connection_schema(user_input or {}),
|
||||
errors=errors or {},
|
||||
)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"sensor": data.fast_coordinator.data.sensor.model_dump(),
|
||||
"dhw": data.fast_coordinator.data.dhw.model_dump(),
|
||||
},
|
||||
"static": data.static.model_dump(),
|
||||
"static": data.static.model_dump() if data.static is not None else None,
|
||||
}
|
||||
|
||||
# Add DHW config and schedule from slow coordinator if available
|
||||
|
||||
@@ -58,7 +58,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device you are trying to reconfigure is not the same as the one previously configured."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -39,6 +41,24 @@
|
||||
"description": "The BSB-LAN integration needs to re-authenticate with {name}",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::bsblan::config::step::user::data_description::host%]",
|
||||
"passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]",
|
||||
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
|
||||
"port": "[%key:component::bsblan::config::step::user::data_description::port%]",
|
||||
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "Update connection settings for your BSB-LAN device.",
|
||||
"title": "Reconfigure BSB-LAN"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
|
||||
39
homeassistant/components/casper_glow/__init__.py
Normal file
39
homeassistant/components/casper_glow/__init__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""The Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pycasperglow import CasperGlow
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
|
||||
"""Set up Casper Glow from a config entry."""
|
||||
address: str = entry.data[CONF_ADDRESS]
|
||||
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Casper Glow device with address {address}"
|
||||
)
|
||||
|
||||
glow = CasperGlow(ble_device)
|
||||
coordinator = CasperGlowCoordinator(hass, glow, entry.title)
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(coordinator.async_start())
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
151
homeassistant/components/casper_glow/config_flow.py
Normal file
151
homeassistant/components/casper_glow/config_flow.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Config flow for Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bluetooth_data_tools import human_readable_name
|
||||
from pycasperglow import CasperGlow, CasperGlowError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import DOMAIN, LOCAL_NAMES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CasperGlowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Casper Glow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
await self.async_set_unique_id(format_mac(discovery_info.address))
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovery_info = discovery_info
|
||||
self.context["title_placeholders"] = {
|
||||
"name": human_readable_name(
|
||||
None, discovery_info.name, discovery_info.address
|
||||
)
|
||||
}
|
||||
return await self.async_step_bluetooth_confirm()
|
||||
|
||||
async def async_step_bluetooth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm a discovered Casper Glow device."""
|
||||
assert self._discovery_info is not None
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=self.context["title_placeholders"]["name"],
|
||||
data={CONF_ADDRESS: self._discovery_info.address},
|
||||
)
|
||||
glow = CasperGlow(self._discovery_info.device)
|
||||
try:
|
||||
await glow.handshake()
|
||||
except CasperGlowError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error during Casper Glow config flow "
|
||||
"(step=bluetooth_confirm, address=%s)",
|
||||
self._discovery_info.address,
|
||||
)
|
||||
return self.async_abort(reason="unknown")
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
description_placeholders=self.context["title_placeholders"],
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step to pick discovered device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
address = user_input[CONF_ADDRESS]
|
||||
discovery_info = self._discovered_devices[address]
|
||||
await self.async_set_unique_id(
|
||||
format_mac(discovery_info.address), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
glow = CasperGlow(discovery_info.device)
|
||||
try:
|
||||
await glow.handshake()
|
||||
except CasperGlowError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Unexpected error during Casper Glow config flow "
|
||||
"(step=user, address=%s)",
|
||||
discovery_info.address,
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=human_readable_name(
|
||||
None, discovery_info.name, discovery_info.address
|
||||
),
|
||||
data={
|
||||
CONF_ADDRESS: discovery_info.address,
|
||||
},
|
||||
)
|
||||
|
||||
if discovery := self._discovery_info:
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
else:
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in async_discovered_service_info(self.hass):
|
||||
if (
|
||||
format_mac(discovery.address) in current_addresses
|
||||
or discovery.address in self._discovered_devices
|
||||
or not (
|
||||
discovery.name
|
||||
and any(
|
||||
discovery.name.startswith(local_name)
|
||||
for local_name in LOCAL_NAMES
|
||||
)
|
||||
)
|
||||
):
|
||||
continue
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(
|
||||
{
|
||||
service_info.address: human_readable_name(
|
||||
None, service_info.name, service_info.address
|
||||
)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
16
homeassistant/components/casper_glow/const.py
Normal file
16
homeassistant/components/casper_glow/const.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Constants for the Casper Glow integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from pycasperglow import BRIGHTNESS_LEVELS, DEVICE_NAME_PREFIX, DIMMING_TIME_MINUTES
|
||||
|
||||
DOMAIN = "casper_glow"
|
||||
|
||||
LOCAL_NAMES = {DEVICE_NAME_PREFIX}
|
||||
|
||||
SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS)
|
||||
|
||||
DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0]
|
||||
|
||||
# Interval between periodic state polls to catch externally-triggered changes.
|
||||
STATE_POLL_INTERVAL = timedelta(seconds=30)
|
||||
103
homeassistant/components/casper_glow/coordinator.py
Normal file
103
homeassistant/components/casper_glow/coordinator.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Coordinator for the Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bleak import BleakError
|
||||
from bluetooth_data_tools import monotonic_time_coarse
|
||||
from pycasperglow import CasperGlow
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothChange,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
)
|
||||
from homeassistant.components.bluetooth.active_update_coordinator import (
|
||||
ActiveBluetoothDataUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import STATE_POLL_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type CasperGlowConfigEntry = ConfigEntry[CasperGlowCoordinator]
|
||||
|
||||
|
||||
class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
|
||||
"""Coordinator for Casper Glow BLE devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
device: CasperGlow,
|
||||
title: str,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
address=device.address,
|
||||
mode=BluetoothScanningMode.PASSIVE,
|
||||
needs_poll_method=self._needs_poll,
|
||||
poll_method=self._async_update,
|
||||
connectable=True,
|
||||
)
|
||||
self.device = device
|
||||
self.last_dimming_time_minutes: int | None = (
|
||||
device.state.configured_dimming_time_minutes
|
||||
)
|
||||
self.title = title
|
||||
|
||||
@callback
|
||||
def _needs_poll(
|
||||
self,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
seconds_since_last_poll: float | None,
|
||||
) -> bool:
|
||||
"""Return True if a poll is needed."""
|
||||
return (
|
||||
seconds_since_last_poll is None
|
||||
or seconds_since_last_poll >= STATE_POLL_INTERVAL.total_seconds()
|
||||
)
|
||||
|
||||
async def _async_update(self, service_info: BluetoothServiceInfoBleak) -> None:
|
||||
"""Poll device state."""
|
||||
await self.device.query_state()
|
||||
|
||||
async def _async_poll(self) -> None:
|
||||
"""Poll the device and log availability changes."""
|
||||
assert self._last_service_info
|
||||
|
||||
try:
|
||||
await self._async_poll_data(self._last_service_info)
|
||||
except BleakError as exc:
|
||||
if self.last_poll_successful:
|
||||
_LOGGER.info("%s is unavailable: %s", self.title, exc)
|
||||
self.last_poll_successful = False
|
||||
return
|
||||
except Exception:
|
||||
if self.last_poll_successful:
|
||||
_LOGGER.exception("%s: unexpected error while polling", self.title)
|
||||
self.last_poll_successful = False
|
||||
return
|
||||
finally:
|
||||
self._last_poll = monotonic_time_coarse()
|
||||
|
||||
if not self.last_poll_successful:
|
||||
_LOGGER.info("%s is back online", self.title)
|
||||
self.last_poll_successful = True
|
||||
|
||||
self._async_handle_bluetooth_poll()
|
||||
|
||||
@callback
|
||||
def _async_handle_bluetooth_event(
|
||||
self,
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
change: BluetoothChange,
|
||||
) -> None:
|
||||
"""Update BLE device reference on each advertisement."""
|
||||
self.device.set_ble_device(service_info.device)
|
||||
super()._async_handle_bluetooth_event(service_info, change)
|
||||
47
homeassistant/components/casper_glow/entity.py
Normal file
47
homeassistant/components/casper_glow/entity.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Base entity for the Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable
|
||||
|
||||
from pycasperglow import CasperGlowError
|
||||
|
||||
from homeassistant.components.bluetooth.passive_update_coordinator import (
|
||||
PassiveBluetoothCoordinatorEntity,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CasperGlowCoordinator
|
||||
|
||||
|
||||
class CasperGlowEntity(PassiveBluetoothCoordinatorEntity[CasperGlowCoordinator]):
|
||||
"""Base class for Casper Glow entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize a Casper Glow entity."""
|
||||
super().__init__(coordinator)
|
||||
self._device = coordinator.device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
manufacturer="Casper",
|
||||
model="Glow",
|
||||
model_id="G01",
|
||||
connections={
|
||||
(dr.CONNECTION_BLUETOOTH, format_mac(coordinator.device.address))
|
||||
},
|
||||
)
|
||||
|
||||
async def _async_command(self, coro: Awaitable[None]) -> None:
|
||||
"""Execute a device command."""
|
||||
try:
|
||||
await coro
|
||||
except CasperGlowError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
104
homeassistant/components/casper_glow/light.py
Normal file
104
homeassistant/components/casper_glow/light.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Casper Glow integration light platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pycasperglow import GlowState
|
||||
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_DIMMING_TIME_MINUTES, SORTED_BRIGHTNESS_LEVELS
|
||||
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
|
||||
from .entity import CasperGlowEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
def _ha_brightness_to_device_pct(brightness: int) -> int:
|
||||
"""Convert HA brightness (1-255) to device percentage by snapping to nearest."""
|
||||
return percentage_to_ordered_list_item(
|
||||
SORTED_BRIGHTNESS_LEVELS, round(brightness * 100 / 255)
|
||||
)
|
||||
|
||||
|
||||
def _device_pct_to_ha_brightness(pct: int) -> int:
|
||||
"""Convert device brightness percentage (60-100) to HA brightness (1-255)."""
|
||||
percent = ordered_list_item_to_percentage(SORTED_BRIGHTNESS_LEVELS, pct)
|
||||
return round(percent * 255 / 100)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CasperGlowConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the light platform for Casper Glow."""
|
||||
async_add_entities([CasperGlowLight(entry.runtime_data)])
|
||||
|
||||
|
||||
class CasperGlowLight(CasperGlowEntity, LightEntity):
|
||||
"""Representation of a Casper Glow light."""
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
|
||||
"""Initialize a Casper Glow light."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = format_mac(coordinator.device.address)
|
||||
self._update_from_state(coordinator.device.state)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state update callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self._device.register_callback(self._async_handle_state_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_state(self, state: GlowState) -> None:
|
||||
"""Update entity attributes from device state."""
|
||||
if state.is_on is not None:
|
||||
self._attr_is_on = state.is_on
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
if state.brightness_level is not None:
|
||||
self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level)
|
||||
|
||||
@callback
|
||||
def _async_handle_state_update(self, state: GlowState) -> None:
|
||||
"""Handle a state update from the device."""
|
||||
self._update_from_state(state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
brightness_pct: int | None = None
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness_pct = _ha_brightness_to_device_pct(kwargs[ATTR_BRIGHTNESS])
|
||||
|
||||
await self._async_command(self._device.turn_on())
|
||||
self._attr_is_on = True
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
if brightness_pct is not None:
|
||||
await self._async_command(
|
||||
self._device.set_brightness_and_dimming_time(
|
||||
brightness_pct,
|
||||
self.coordinator.last_dimming_time_minutes
|
||||
if self.coordinator.last_dimming_time_minutes is not None
|
||||
else DEFAULT_DIMMING_TIME_MINUTES,
|
||||
)
|
||||
)
|
||||
self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off."""
|
||||
await self._async_command(self._device.turn_off())
|
||||
self._attr_is_on = False
|
||||
19
homeassistant/components/casper_glow/manifest.json
Normal file
19
homeassistant/components/casper_glow/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"domain": "casper_glow",
|
||||
"name": "Casper Glow",
|
||||
"bluetooth": [
|
||||
{
|
||||
"connectable": true,
|
||||
"local_name": "Jar*"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@mikeodr"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/casper_glow",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycasperglow"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycasperglow==1.1.0"]
|
||||
}
|
||||
74
homeassistant/components/casper_glow/quality_scale.yaml
Normal file
74
homeassistant/components/casper_glow/quality_scale.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: No custom 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: No custom actions/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: 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:
|
||||
status: exempt
|
||||
comment: No network discovery.
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: No entity translations needed.
|
||||
exception-translations:
|
||||
status: exempt
|
||||
comment: No custom services that raise exceptions.
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: No icon translations needed.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: No web session is used by this integration.
|
||||
strict-typing: done
|
||||
34
homeassistant/components/casper_glow/strings.json
Normal file
34
homeassistant/components/casper_glow/strings.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"bluetooth_confirm": {
|
||||
"description": "Do you want to set up {name}?"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"address": "Bluetooth address"
|
||||
},
|
||||
"data_description": {
|
||||
"address": "The Bluetooth address of the Casper Glow light"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"communication_error": {
|
||||
"message": "An error occurred while communicating with the Casper Glow: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ class ClementineDevice(MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
)
|
||||
_attr_volume_step = 0.04
|
||||
|
||||
def __init__(self, client, name):
|
||||
"""Initialize the Clementine device."""
|
||||
@@ -124,16 +125,6 @@ class ClementineDevice(MediaPlayerEntity):
|
||||
|
||||
return None, None
|
||||
|
||||
def volume_up(self) -> None:
|
||||
"""Volume up the media player."""
|
||||
newvolume = min(self._client.volume + 4, 100)
|
||||
self._client.set_volume(newvolume)
|
||||
|
||||
def volume_down(self) -> None:
|
||||
"""Volume down media player."""
|
||||
newvolume = max(self._client.volume - 4, 0)
|
||||
self._client.set_volume(newvolume)
|
||||
|
||||
def mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
self._client.set_volume(0)
|
||||
|
||||
@@ -32,6 +32,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .condition import make_cover_is_closed_condition, make_cover_is_open_condition
|
||||
from .const import (
|
||||
ATTR_CURRENT_POSITION,
|
||||
ATTR_CURRENT_TILT_POSITION,
|
||||
@@ -80,6 +81,8 @@ __all__ = [
|
||||
"CoverEntityFeature",
|
||||
"CoverState",
|
||||
"make_cover_closed_trigger",
|
||||
"make_cover_is_closed_condition",
|
||||
"make_cover_is_open_condition",
|
||||
"make_cover_opened_trigger",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
@@ -13,14 +13,14 @@ class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
|
||||
|
||||
def _get_value(self, state: State) -> str | bool | None:
|
||||
"""Extract the relevant value from state based on domain spec."""
|
||||
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
|
||||
domain_spec = self._domain_specs[state.domain]
|
||||
if domain_spec.value_source is not None:
|
||||
return state.attributes.get(domain_spec.value_source)
|
||||
return state.state
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target cover state."""
|
||||
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
|
||||
domain_spec = self._domain_specs[state.domain]
|
||||
return self._get_value(state) == domain_spec.target_value
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
|
||||
29
homeassistant/components/door/condition.py
Normal file
29
homeassistant/components/door/condition.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Provides conditions for doors."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.cover import (
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
CoverDeviceClass,
|
||||
make_cover_is_closed_condition,
|
||||
make_cover_is_open_condition,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition
|
||||
|
||||
DEVICE_CLASSES_DOOR: dict[str, str] = {
|
||||
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR,
|
||||
COVER_DOMAIN: CoverDeviceClass.DOOR,
|
||||
}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_DOOR),
|
||||
"is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_DOOR),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for doors."""
|
||||
return CONDITIONS
|
||||
28
homeassistant/components/door/conditions.yaml
Normal file
28
homeassistant/components/door/conditions.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
.condition_common_fields: &condition_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: door
|
||||
- domain: cover
|
||||
device_class: door
|
||||
|
||||
is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: door
|
||||
- domain: cover
|
||||
device_class: door
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_closed": {
|
||||
"condition": "mdi:door-closed"
|
||||
},
|
||||
"is_open": {
|
||||
"condition": "mdi:door-open"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:door-closed"
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted doors.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted doors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_closed": {
|
||||
"description": "Tests if one or more doors are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::door::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::door::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Door is closed"
|
||||
},
|
||||
"is_open": {
|
||||
"description": "Tests if one or more doors are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::door::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::door::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Door is open"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
64
homeassistant/components/dropbox/__init__.py
Normal file
64
homeassistant/components/dropbox/__init__.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""The Dropbox integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_dropbox_api import (
|
||||
DropboxAPIClient,
|
||||
DropboxAuthException,
|
||||
DropboxUnknownException,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from .auth import DropboxConfigEntryAuth
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
type DropboxConfigEntry = ConfigEntry[DropboxAPIClient]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
|
||||
"""Set up Dropbox from a config entry."""
|
||||
try:
|
||||
oauth2_implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
oauth2_session = OAuth2Session(hass, entry, oauth2_implementation)
|
||||
|
||||
auth = DropboxConfigEntryAuth(
|
||||
aiohttp_client.async_get_clientsession(hass), oauth2_session
|
||||
)
|
||||
|
||||
client = DropboxAPIClient(auth)
|
||||
|
||||
try:
|
||||
await client.get_account_info()
|
||||
except DropboxAuthException as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (DropboxUnknownException, TimeoutError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
entry.runtime_data = client
|
||||
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return True
|
||||
38
homeassistant/components/dropbox/application_credentials.py
Normal file
38
homeassistant/components/dropbox/application_credentials.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Application credentials platform for the Dropbox integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
AbstractOAuth2Implementation,
|
||||
LocalOAuth2ImplementationWithPkce,
|
||||
)
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
|
||||
) -> AbstractOAuth2Implementation:
|
||||
"""Return custom auth implementation."""
|
||||
return DropboxOAuth2Implementation(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential.client_id,
|
||||
OAUTH2_AUTHORIZE,
|
||||
OAUTH2_TOKEN,
|
||||
credential.client_secret,
|
||||
)
|
||||
|
||||
|
||||
class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
|
||||
"""Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters."""
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
data: dict = {
|
||||
"token_access_type": "offline",
|
||||
"scope": " ".join(OAUTH2_SCOPES),
|
||||
}
|
||||
data.update(super().extra_authorize_data)
|
||||
return data
|
||||
44
homeassistant/components/dropbox/auth.py
Normal file
44
homeassistant/components/dropbox/auth.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Authentication for Dropbox."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from python_dropbox_api import Auth
|
||||
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
|
||||
|
||||
class DropboxConfigEntryAuth(Auth):
|
||||
"""Provide Dropbox authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
oauth_session: OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize DropboxConfigEntryAuth."""
|
||||
super().__init__(websession)
|
||||
self._oauth_session = oauth_session
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
await self._oauth_session.async_ensure_token_valid()
|
||||
|
||||
return cast(str, self._oauth_session.token["access_token"])
|
||||
|
||||
|
||||
class DropboxConfigFlowAuth(Auth):
|
||||
"""Provide authentication tied to a fixed token for the config flow."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
websession: ClientSession,
|
||||
token: str,
|
||||
) -> None:
|
||||
"""Initialize DropboxConfigFlowAuth."""
|
||||
super().__init__(websession)
|
||||
self._token = token
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return the fixed access token."""
|
||||
return self._token
|
||||
230
homeassistant/components/dropbox/backup.py
Normal file
230
homeassistant/components/dropbox/backup.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Backup platform for the Dropbox integration."""
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from python_dropbox_api import (
|
||||
DropboxAPIClient,
|
||||
DropboxAuthException,
|
||||
DropboxFileOrFolderNotFoundException,
|
||||
DropboxUnknownException,
|
||||
)
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import DropboxConfigEntry
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata."""
|
||||
base_name = suggested_filename(backup).rsplit(".", 1)[0]
|
||||
return f"{base_name}.tar", f"{base_name}.metadata.json"
|
||||
|
||||
|
||||
async def _async_string_iterator(content: str) -> AsyncIterator[bytes]:
|
||||
"""Yield a string as a single bytes chunk."""
|
||||
yield content.encode()
|
||||
|
||||
|
||||
def handle_backup_errors[_R, **P](
|
||||
func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]],
|
||||
) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]:
|
||||
"""Handle backup errors."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs
|
||||
) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except DropboxFileOrFolderNotFoundException as err:
|
||||
raise BackupNotFound(
|
||||
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
|
||||
) from err
|
||||
except DropboxAuthException as err:
|
||||
self._entry.async_start_reauth(self._hass)
|
||||
raise BackupAgentError("Authentication error") from err
|
||||
except DropboxUnknownException as err:
|
||||
_LOGGER.error(
|
||||
"Error during %s: %s",
|
||||
func.__name__,
|
||||
err,
|
||||
)
|
||||
_LOGGER.debug("Full error: %s", err, exc_info=True)
|
||||
raise BackupAgentError(
|
||||
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
**kwargs: Any,
|
||||
) -> list[BackupAgent]:
|
||||
"""Return a list of backup agents."""
|
||||
entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||
return [DropboxBackupAgent(hass, entry) for entry in entries]
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_agents_listener(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
listener: Callable[[], None],
|
||||
**kwargs: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a listener to be called when agents are added or removed.
|
||||
|
||||
:return: A function to unregister the listener.
|
||||
"""
|
||||
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove the listener."""
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
|
||||
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
|
||||
del hass.data[DATA_BACKUP_AGENT_LISTENERS]
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
class DropboxBackupAgent(BackupAgent):
|
||||
"""Backup agent for the Dropbox integration."""
|
||||
|
||||
domain = DOMAIN
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None:
|
||||
"""Initialize the backup agent."""
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self._entry = entry
|
||||
self.name = entry.title
|
||||
assert entry.unique_id
|
||||
self.unique_id = entry.unique_id
|
||||
self._api: DropboxAPIClient = entry.runtime_data
|
||||
|
||||
async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]:
|
||||
"""Get backups and their corresponding file names."""
|
||||
files = await self._api.list_folder("")
|
||||
|
||||
tar_files = {f.name for f in files if f.name.endswith(".tar")}
|
||||
metadata_files = [f for f in files if f.name.endswith(".metadata.json")]
|
||||
|
||||
backups: list[tuple[AgentBackup, str]] = []
|
||||
for metadata_file in metadata_files:
|
||||
tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar"
|
||||
if tar_name not in tar_files:
|
||||
_LOGGER.warning(
|
||||
"Found metadata file '%s' without matching backup file",
|
||||
metadata_file.name,
|
||||
)
|
||||
continue
|
||||
|
||||
metadata_stream = self._api.download_file(f"/{metadata_file.name}")
|
||||
raw = b"".join([chunk async for chunk in metadata_stream])
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
backup = AgentBackup.from_dict(data)
|
||||
except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err:
|
||||
_LOGGER.warning(
|
||||
"Skipping invalid metadata file '%s': %s",
|
||||
metadata_file.name,
|
||||
err,
|
||||
)
|
||||
continue
|
||||
backups.append((backup, tar_name))
|
||||
|
||||
return backups
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
backup_filename, metadata_filename = _suggested_filenames(backup)
|
||||
backup_path = f"/{backup_filename}"
|
||||
metadata_path = f"/{metadata_filename}"
|
||||
|
||||
file_stream = await open_stream()
|
||||
await self._api.upload_file(backup_path, file_stream)
|
||||
|
||||
metadata_stream = _async_string_iterator(json.dumps(backup.as_dict()))
|
||||
|
||||
try:
|
||||
await self._api.upload_file(metadata_path, metadata_stream)
|
||||
except (
|
||||
DropboxAuthException,
|
||||
DropboxUnknownException,
|
||||
):
|
||||
await self._api.delete_file(backup_path)
|
||||
raise
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
return [backup for backup, _ in await self._async_get_backups()]
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file."""
|
||||
backups = await self._async_get_backups()
|
||||
for backup, filename in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
return self._api.download_file(f"/{filename}")
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup:
|
||||
"""Return a backup."""
|
||||
backups = await self._async_get_backups()
|
||||
|
||||
for backup, _ in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
return backup
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file."""
|
||||
backups = await self._async_get_backups()
|
||||
for backup, tar_filename in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json"
|
||||
await self._api.delete_file(f"/{tar_filename}")
|
||||
await self._api.delete_file(f"/{metadata_filename}")
|
||||
return
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
60
homeassistant/components/dropbox/config_flow.py
Normal file
60
homeassistant/components/dropbox/config_flow.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Config flow for Dropbox."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from python_dropbox_api import DropboxAPIClient
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
|
||||
from .auth import DropboxConfigFlowAuth
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Config flow to handle Dropbox OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow, or update existing entry."""
|
||||
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
|
||||
|
||||
auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token)
|
||||
|
||||
client = DropboxAPIClient(auth)
|
||||
account_info = await client.get_account_info()
|
||||
|
||||
await self.async_set_unique_id(account_info.account_id)
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(title=account_info.email, data=data)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Dialog that informs the user that reauth is required."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
19
homeassistant/components/dropbox/const.py
Normal file
19
homeassistant/components/dropbox/const.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Constants for the Dropbox integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN = "dropbox"
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize"
|
||||
OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token"
|
||||
OAUTH2_SCOPES = [
|
||||
"account_info.read",
|
||||
"files.content.read",
|
||||
"files.content.write",
|
||||
]
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
13
homeassistant/components/dropbox/manifest.json
Normal file
13
homeassistant/components/dropbox/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"domain": "dropbox",
|
||||
"name": "Dropbox",
|
||||
"after_dependencies": ["backup"],
|
||||
"codeowners": ["@bdr99"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dropbox",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-dropbox-api==0.1.3"]
|
||||
}
|
||||
112
homeassistant/components/dropbox/quality_scale.yaml
Normal file
112
homeassistant/components/dropbox/quality_scale.yaml
Normal file
@@ -0,0 +1,112 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register any actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: Integration does not poll.
|
||||
brands: done
|
||||
common-modules:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities or coordinators.
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register any actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register any actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration does not have any configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: Integration does not make any entity updates.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
diagnostics:
|
||||
status: exempt
|
||||
comment: Integration does not have any data to diagnose.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration is a service.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Integration is a service.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: Integration does not update any data.
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: Integration only provides backup functionality.
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: Integration does not support any devices.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Integration does not use any devices.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Integration does not have any entities.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not have any repairs.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Integration does not have any devices.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
35
homeassistant/components/dropbox/strings.json
Normal file
35
homeassistant/components/dropbox/strings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"wrong_account": "Wrong account: Please authenticate with the correct account."
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "The Dropbox integration needs to re-authenticate your account.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ from typing import Any, Literal, NotRequired, TypedDict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||
from homeassistant.helpers import config_validation as cv, singleton, storage
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -244,6 +244,38 @@ class EnergyPreferencesUpdate(EnergyPreferences, total=False):
|
||||
"""all types optional."""
|
||||
|
||||
|
||||
def _reject_price_for_external_stat(
|
||||
*,
|
||||
stat_key: str,
|
||||
entity_price_key: str = "entity_energy_price",
|
||||
number_price_key: str = "number_energy_price",
|
||||
cost_stat_key: str = "stat_cost",
|
||||
) -> Callable[[dict[str, Any]], dict[str, Any]]:
|
||||
"""Return a validator that rejects entity/number price for external statistics.
|
||||
|
||||
Only rejects when the cost/compensation stat is not already set, since
|
||||
price fields are ignored when a cost stat is provided.
|
||||
"""
|
||||
|
||||
def validate(val: dict[str, Any]) -> dict[str, Any]:
|
||||
stat_id = val.get(stat_key)
|
||||
if stat_id is not None and not valid_entity_id(stat_id):
|
||||
if val.get(cost_stat_key) is not None:
|
||||
# Cost stat is already set; price fields are ignored, so allow.
|
||||
return val
|
||||
if (
|
||||
val.get(entity_price_key) is not None
|
||||
or val.get(number_price_key) is not None
|
||||
):
|
||||
raise vol.Invalid(
|
||||
"Entity or number price is not supported for external"
|
||||
f" statistics. Use {cost_stat_key} instead"
|
||||
)
|
||||
return val
|
||||
|
||||
return validate
|
||||
|
||||
|
||||
def _flow_from_ensure_single_price(
|
||||
val: FlowFromGridSourceType,
|
||||
) -> FlowFromGridSourceType:
|
||||
@@ -268,19 +300,25 @@ FLOW_FROM_GRID_SOURCE_SCHEMA = vol.All(
|
||||
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
||||
}
|
||||
),
|
||||
_reject_price_for_external_stat(stat_key="stat_energy_from"),
|
||||
_flow_from_ensure_single_price,
|
||||
)
|
||||
|
||||
|
||||
FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_energy_to"): str,
|
||||
vol.Optional("stat_compensation"): vol.Any(str, None),
|
||||
# entity_energy_to was removed in HA Core 2022.10
|
||||
vol.Remove("entity_energy_to"): vol.Any(str, None),
|
||||
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
||||
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
||||
}
|
||||
FLOW_TO_GRID_SOURCE_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("stat_energy_to"): str,
|
||||
vol.Optional("stat_compensation"): vol.Any(str, None),
|
||||
# entity_energy_to was removed in HA Core 2022.10
|
||||
vol.Remove("entity_energy_to"): vol.Any(str, None),
|
||||
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
||||
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
||||
}
|
||||
),
|
||||
_reject_price_for_external_stat(
|
||||
stat_key="stat_energy_to", cost_stat_key="stat_compensation"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -419,6 +457,13 @@ GRID_SOURCE_SCHEMA = vol.All(
|
||||
vol.Required("cost_adjustment_day"): vol.Coerce(float),
|
||||
}
|
||||
),
|
||||
_reject_price_for_external_stat(stat_key="stat_energy_from"),
|
||||
_reject_price_for_external_stat(
|
||||
stat_key="stat_energy_to",
|
||||
entity_price_key="entity_energy_price_export",
|
||||
number_price_key="number_energy_price_export",
|
||||
cost_stat_key="stat_compensation",
|
||||
),
|
||||
_grid_ensure_single_price_import,
|
||||
_grid_ensure_single_price_export,
|
||||
_grid_ensure_at_least_one_stat,
|
||||
@@ -442,27 +487,35 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
}
|
||||
)
|
||||
GAS_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "gas",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("stat_cost"): vol.Any(str, None),
|
||||
# entity_energy_from was removed in HA Core 2022.10
|
||||
vol.Remove("entity_energy_from"): vol.Any(str, None),
|
||||
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
||||
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
||||
}
|
||||
|
||||
|
||||
GAS_SOURCE_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "gas",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("stat_cost"): vol.Any(str, None),
|
||||
# entity_energy_from was removed in HA Core 2022.10
|
||||
vol.Remove("entity_energy_from"): vol.Any(str, None),
|
||||
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
||||
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
||||
}
|
||||
),
|
||||
_reject_price_for_external_stat(stat_key="stat_energy_from"),
|
||||
)
|
||||
WATER_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "water",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("stat_cost"): vol.Any(str, None),
|
||||
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
||||
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
||||
}
|
||||
WATER_SOURCE_SCHEMA = vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "water",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("stat_cost"): vol.Any(str, None),
|
||||
vol.Optional("entity_energy_price"): vol.Any(str, None),
|
||||
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
|
||||
}
|
||||
),
|
||||
_reject_price_for_external_stat(stat_key="stat_energy_from"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,23 @@
|
||||
"alarm_sound_mode": {
|
||||
"default": "mdi:alarm"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"alarm_sound_mode": {
|
||||
"default": "mdi:alarm"
|
||||
},
|
||||
"last_alarm_type_code": {
|
||||
"default": "mdi:alarm"
|
||||
},
|
||||
"last_alarm_type_name": {
|
||||
"default": "mdi:alarm"
|
||||
},
|
||||
"local_ip": {
|
||||
"default": "mdi:ip"
|
||||
},
|
||||
"wan_ip": {
|
||||
"default": "mdi:ip"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -283,6 +283,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._username = user_input[CONF_USERNAME]
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
self._use_tls = user_input[CONF_SSL]
|
||||
self._feature_device_discovery = user_input[CONF_FEATURE_DEVICE_TRACKING]
|
||||
|
||||
self._port = self._determine_port(user_input)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ class FullyButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Fully Kiosk Browser button description."""
|
||||
|
||||
press_action: Callable[[FullyKiosk], Any]
|
||||
refresh_after_press: bool = True
|
||||
|
||||
|
||||
BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
|
||||
@@ -68,6 +69,13 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda fully: fully.clearCache(),
|
||||
),
|
||||
FullyButtonEntityDescription(
|
||||
key="triggerMotion",
|
||||
translation_key="trigger_motion",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda fully: fully.triggerMotion(),
|
||||
refresh_after_press=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -102,4 +110,5 @@ class FullyButtonEntity(FullyKioskEntity, ButtonEntity):
|
||||
async def async_press(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
await self.entity_description.press_action(self.coordinator.fully)
|
||||
await self.coordinator.async_refresh()
|
||||
if self.entity_description.refresh_after_press:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@@ -12,7 +12,12 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfInformation,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -56,6 +61,14 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
FullySensorEntityDescription(
|
||||
key="batteryTemperature",
|
||||
translation_key="battery_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
FullySensorEntityDescription(
|
||||
key="currentPage",
|
||||
translation_key="current_page",
|
||||
|
||||
@@ -88,6 +88,9 @@
|
||||
},
|
||||
"to_foreground": {
|
||||
"name": "Bring to foreground"
|
||||
},
|
||||
"trigger_motion": {
|
||||
"name": "Trigger motion activity"
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
@@ -118,6 +121,9 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"battery_temperature": {
|
||||
"name": "Battery temperature"
|
||||
},
|
||||
"current_page": {
|
||||
"name": "Current page"
|
||||
},
|
||||
|
||||
31
homeassistant/components/garage_door/condition.py
Normal file
31
homeassistant/components/garage_door/condition.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Provides conditions for garage doors."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.cover import (
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
CoverDeviceClass,
|
||||
make_cover_is_closed_condition,
|
||||
make_cover_is_open_condition,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition
|
||||
|
||||
DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
|
||||
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.GARAGE_DOOR,
|
||||
COVER_DOMAIN: CoverDeviceClass.GARAGE,
|
||||
}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_closed": make_cover_is_closed_condition(
|
||||
device_classes=DEVICE_CLASSES_GARAGE_DOOR
|
||||
),
|
||||
"is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for garage doors."""
|
||||
return CONDITIONS
|
||||
28
homeassistant/components/garage_door/conditions.yaml
Normal file
28
homeassistant/components/garage_door/conditions.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
.condition_common_fields: &condition_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: garage_door
|
||||
- domain: cover
|
||||
device_class: garage
|
||||
|
||||
is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: garage_door
|
||||
- domain: cover
|
||||
device_class: garage
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_closed": {
|
||||
"condition": "mdi:garage"
|
||||
},
|
||||
"is_open": {
|
||||
"condition": "mdi:garage-open"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:garage"
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted garage doors.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_closed": {
|
||||
"description": "Tests if one or more garage doors are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::garage_door::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door is closed"
|
||||
},
|
||||
"is_open": {
|
||||
"description": "Tests if one or more garage doors are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::garage_door::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door is open"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
24
homeassistant/components/gate/condition.py
Normal file
24
homeassistant/components/gate/condition.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Provides conditions for gates."""
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
CoverDeviceClass,
|
||||
make_cover_is_closed_condition,
|
||||
make_cover_is_open_condition,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition
|
||||
|
||||
DEVICE_CLASSES_GATE: dict[str, str] = {
|
||||
COVER_DOMAIN: CoverDeviceClass.GATE,
|
||||
}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_closed": make_cover_is_closed_condition(device_classes=DEVICE_CLASSES_GATE),
|
||||
"is_open": make_cover_is_open_condition(device_classes=DEVICE_CLASSES_GATE),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for gates."""
|
||||
return CONDITIONS
|
||||
24
homeassistant/components/gate/conditions.yaml
Normal file
24
homeassistant/components/gate/conditions.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
.condition_common_fields: &condition_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: gate
|
||||
|
||||
is_open:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: gate
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_closed": {
|
||||
"condition": "mdi:gate"
|
||||
},
|
||||
"is_open": {
|
||||
"condition": "mdi:gate-open"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:gate"
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted gates.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted gates to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_closed": {
|
||||
"description": "Tests if one or more gates are closed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::gate::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::gate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate is closed"
|
||||
},
|
||||
"is_open": {
|
||||
"description": "Tests if one or more gates are open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::gate::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::gate::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate is open"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
@@ -30,11 +31,17 @@ _PLATFORMS = (Platform.SENSOR,)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry) -> bool:
|
||||
"""Set up Google Drive from a config entry."""
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
auth = AsyncConfigEntryAuth(
|
||||
async_get_clientsession(hass),
|
||||
OAuth2Session(
|
||||
hass, entry, await async_get_config_entry_implementation(hass, entry)
|
||||
),
|
||||
OAuth2Session(hass, entry, implementation),
|
||||
)
|
||||
|
||||
# Test we can refresh the token and raise ConfigEntryAuthFailed or ConfigEntryNotReady if not
|
||||
@@ -46,7 +53,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
|
||||
try:
|
||||
folder_id, _ = await client.async_create_ha_root_folder_if_not_exists()
|
||||
except GoogleDriveApiError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_get_folder",
|
||||
translation_placeholders={"folder": "Home Assistant"},
|
||||
) from err
|
||||
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
|
||||
@@ -22,6 +22,8 @@ from homeassistant.exceptions import (
|
||||
)
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_UPLOAD_AND_DOWNLOAD_TIMEOUT = 12 * 3600
|
||||
_UPLOAD_MAX_RETRIES = 20
|
||||
|
||||
@@ -61,14 +63,21 @@ class AsyncConfigEntryAuth(AbstractAuth):
|
||||
):
|
||||
if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_not_valid",
|
||||
) from ex
|
||||
raise ConfigEntryNotReady from ex
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from ex
|
||||
if hasattr(ex, "status") and ex.status == 400:
|
||||
self._oauth_session.config_entry.async_start_reauth(
|
||||
self._oauth_session.hass
|
||||
)
|
||||
raise HomeAssistantError(ex) from ex
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_failed",
|
||||
) from ex
|
||||
return str(self._oauth_session.token[CONF_ACCESS_TOKEN])
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ from typing import Any, cast
|
||||
|
||||
from google_drive_api.exceptions import GoogleDriveApiError
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -44,6 +48,12 @@ class OAuth2FlowHandler(
|
||||
"prompt": "consent",
|
||||
}
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reconfiguration flow."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
@@ -81,13 +91,16 @@ class OAuth2FlowHandler(
|
||||
|
||||
await self.async_set_unique_id(email_address)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if self.source in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
|
||||
if self.source == SOURCE_REAUTH:
|
||||
entry = self._get_reauth_entry()
|
||||
else:
|
||||
entry = self._get_reconfigure_entry()
|
||||
self._abort_if_unique_id_mismatch(
|
||||
reason="wrong_account",
|
||||
description_placeholders={"email": cast(str, reauth_entry.unique_id)},
|
||||
description_placeholders={"email": cast(str, entry.unique_id)},
|
||||
)
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=data)
|
||||
return self.async_update_reload_and_abort(entry, data=data)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
|
||||
@@ -17,9 +17,7 @@ rules:
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
@@ -66,12 +64,8 @@ rules:
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: No entities.
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: No configuration options.
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repairs.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
|
||||
"wrong_account": "Wrong account: Please authenticate with {email}."
|
||||
@@ -62,5 +63,22 @@
|
||||
"name": "Used storage in Drive Trash"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"authentication_failed": {
|
||||
"message": "Authentication failed"
|
||||
},
|
||||
"authentication_not_valid": {
|
||||
"message": "OAuth session is not valid, reauthentication required"
|
||||
},
|
||||
"failed_to_get_folder": {
|
||||
"message": "Failed to get {folder} folder"
|
||||
},
|
||||
"invalid_response_google_drive_error": {
|
||||
"message": "Invalid response from Google Drive: {error}"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ from homeassistant.helpers.update_coordinator import (
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar(
|
||||
@@ -97,7 +99,13 @@ class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
|
||||
self.subentry.title,
|
||||
err,
|
||||
)
|
||||
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
class GoogleWeatherCurrentConditionsCoordinator(
|
||||
|
||||
@@ -66,7 +66,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
|
||||
@@ -98,5 +98,10 @@
|
||||
"name": "Wind gust speed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_error": {
|
||||
"message": "Error fetching weather data: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
def get_data(
|
||||
self, entity_description: GrowattSensorEntityDescription
|
||||
) -> str | int | float | None:
|
||||
) -> str | int | float | datetime.datetime | datetime.date | None:
|
||||
"""Get the data."""
|
||||
variable = entity_description.api_key
|
||||
api_value = self.data.get(variable)
|
||||
@@ -372,7 +372,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating time segments requires token authentication"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="token_auth_required",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -388,7 +389,11 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
enabled,
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(f"API error updating time segment: {err}") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
# Update coordinator's cached data without making an API call (avoids rate limit)
|
||||
if self.data:
|
||||
@@ -411,7 +416,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading time segments requires token authentication"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="token_auth_required",
|
||||
)
|
||||
|
||||
# Ensure we have current data
|
||||
@@ -496,7 +502,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating AC charge times requires token authentication"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="token_auth_required",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -510,7 +517,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(
|
||||
f"API error updating AC charge times: {err}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
if self.data:
|
||||
@@ -544,7 +553,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Updating AC discharge times requires token authentication"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="token_auth_required",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -557,7 +567,9 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
except growattServer.GrowattV1ApiError as err:
|
||||
raise HomeAssistantError(
|
||||
f"API error updating AC discharge times: {err}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
if self.data:
|
||||
@@ -579,7 +591,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Read AC charge time settings from SPH device cache."""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading AC charge times requires token authentication"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="token_auth_required",
|
||||
)
|
||||
|
||||
if not self.data:
|
||||
@@ -591,7 +604,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Read AC discharge time settings from SPH device cache."""
|
||||
if self.api_version != "v1":
|
||||
raise ServiceValidationError(
|
||||
"Reading AC discharge times requires token authentication"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="token_auth_required",
|
||||
)
|
||||
|
||||
if not self.data:
|
||||
|
||||
65
homeassistant/components/growatt_server/diagnostics.py
Normal file
65
homeassistant/components/growatt_server/diagnostics.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Diagnostics support for Growatt Server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_UNIQUE_ID, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_PLANT_ID
|
||||
from .coordinator import GrowattConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_PLANT_ID,
|
||||
"user_id",
|
||||
"deviceSn",
|
||||
"device_sn",
|
||||
}
|
||||
|
||||
# Allowlist of safe telemetry fields from the total coordinator.
|
||||
# Monetary fields (plantMoneyText, totalMoneyText, currency) are intentionally
|
||||
# excluded to avoid leaking financial data under unpredictable key names.
|
||||
_TOTAL_SAFE_KEYS = frozenset(
|
||||
{
|
||||
# Classic API keys
|
||||
"todayEnergy",
|
||||
"totalEnergy",
|
||||
"invTodayPpv",
|
||||
"nominalPower",
|
||||
# V1 API keys (aliases used after normalisation in coordinator)
|
||||
"today_energy",
|
||||
"total_energy",
|
||||
"current_power",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: GrowattConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
runtime_data = config_entry.runtime_data
|
||||
total_data = runtime_data.total_coordinator.data or {}
|
||||
return async_redact_data(
|
||||
{
|
||||
"config_entry": config_entry.as_dict(),
|
||||
"total_coordinator": {
|
||||
k: v for k, v in total_data.items() if k in _TOTAL_SAFE_KEYS
|
||||
},
|
||||
"devices": [
|
||||
{
|
||||
"device_sn": device_sn,
|
||||
"device_type": coordinator.device_type,
|
||||
"data": coordinator.data,
|
||||
}
|
||||
for device_sn, coordinator in runtime_data.devices.items()
|
||||
],
|
||||
},
|
||||
TO_REDACT,
|
||||
)
|
||||
@@ -1,4 +1,17 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"storage_load_consumption_solar_storage": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"total_money_today": {
|
||||
"default": "mdi:cash"
|
||||
},
|
||||
"total_money_total": {
|
||||
"default": "mdi:cash"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"read_ac_charge_times": {
|
||||
"service": "mdi:battery-clock-outline"
|
||||
|
||||
@@ -17,7 +17,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,9 +26,10 @@ PARALLEL_UPDATES = (
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GrowattNumberEntityDescription(NumberEntityDescription, GrowattRequiredKeysMixin):
|
||||
class GrowattNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes Growatt number entity."""
|
||||
|
||||
api_key: str
|
||||
write_key: str | None = None # Parameter ID for writing (if different from api_key)
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
|
||||
identifiers={(DOMAIN, coordinator.device_id)},
|
||||
manufacturer="Growatt",
|
||||
name=coordinator.device_id,
|
||||
serial_number=coordinator.device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -157,7 +158,11 @@ class GrowattNumber(CoordinatorEntity[GrowattCoordinator], NumberEntity):
|
||||
int_value,
|
||||
)
|
||||
except GrowattV1ApiError as e:
|
||||
raise HomeAssistantError(f"Error while setting parameter: {e}") from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": str(e)},
|
||||
) from e
|
||||
|
||||
# If no exception was raised, the write was successful
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -32,10 +32,8 @@ rules:
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: todo
|
||||
comment: Add serial_number field to DeviceInfo in sensor, number, and switch platforms using device_id/serial_id.
|
||||
diagnostics: todo
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
@@ -46,16 +44,12 @@ rules:
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: todo
|
||||
comment: Add EntityCategory.DIAGNOSTIC to temperature and other diagnostic sensors. Merge GrowattRequiredKeysMixin into GrowattSensorEntityDescription using kw_only=True.
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
|
||||
entity-disabled-by-default: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
import logging
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from ..const import DOMAIN
|
||||
@@ -99,24 +101,18 @@ class GrowattSensor(CoordinatorEntity[GrowattCoordinator], SensorEntity):
|
||||
self.entity_description = description
|
||||
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_icon = "mdi:solar-power"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_id)},
|
||||
manufacturer="Growatt",
|
||||
name=name,
|
||||
serial_number=serial_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | float | None:
|
||||
def native_value(self) -> StateType | date | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
result = self.coordinator.get_data(self.entity_description)
|
||||
if (
|
||||
isinstance(result, (int, float))
|
||||
and self.entity_description.precision is not None
|
||||
):
|
||||
result = round(result, self.entity_description.precision)
|
||||
return result
|
||||
return self.coordinator.get_data(self.entity_description)
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -22,7 +23,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_energy_total",
|
||||
@@ -30,7 +31,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="powerTotal",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
@@ -40,7 +41,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_amperage_input_1",
|
||||
@@ -49,7 +50,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_wattage_input_1",
|
||||
@@ -58,7 +59,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_voltage_input_2",
|
||||
@@ -67,7 +68,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_amperage_input_2",
|
||||
@@ -76,7 +77,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_wattage_input_2",
|
||||
@@ -85,7 +86,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_voltage_input_3",
|
||||
@@ -94,7 +95,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_amperage_input_3",
|
||||
@@ -103,7 +104,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_wattage_input_3",
|
||||
@@ -112,7 +113,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_internal_wattage",
|
||||
@@ -121,7 +122,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_reactive_voltage",
|
||||
@@ -130,7 +131,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_inverter_reactive_amperage",
|
||||
@@ -139,7 +142,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_frequency",
|
||||
@@ -148,7 +153,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_current_wattage",
|
||||
@@ -157,7 +164,7 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_current_reactive_wattage",
|
||||
@@ -166,7 +173,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_ipm_temperature",
|
||||
@@ -175,7 +184,9 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="inverter_temperature",
|
||||
@@ -184,6 +195,8 @@ INVERTER_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,18 +7,11 @@ from dataclasses import dataclass
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrowattRequiredKeysMixin:
|
||||
"""Mixin for required keys."""
|
||||
|
||||
api_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin):
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GrowattSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Growatt sensor entity."""
|
||||
|
||||
precision: int | None = None
|
||||
api_key: str
|
||||
currency: bool = False
|
||||
previous_value_drop_threshold: float | None = None
|
||||
never_resets: bool = False
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfFrequency,
|
||||
@@ -90,6 +91,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_1",
|
||||
@@ -98,6 +101,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_2",
|
||||
@@ -106,6 +111,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_3",
|
||||
@@ -114,6 +121,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_4",
|
||||
@@ -122,6 +131,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="sph_temperature_5",
|
||||
@@ -130,6 +141,8 @@ SPH_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Values from 'sph_energy' API call
|
||||
GrowattSensorEntityDescription(
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -189,7 +190,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_pv_charging_voltage",
|
||||
@@ -198,7 +199,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_pv_charging_voltage_2",
|
||||
@@ -207,7 +208,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_ac_input_frequency_out",
|
||||
@@ -216,7 +217,9 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_output_voltage",
|
||||
@@ -225,7 +228,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_ac_output_frequency",
|
||||
@@ -234,7 +237,9 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_current_PV",
|
||||
@@ -243,7 +248,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_current_1",
|
||||
@@ -252,7 +257,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_current_2",
|
||||
@@ -261,7 +266,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_grid_amperage_input",
|
||||
@@ -270,7 +275,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_grid_out_current",
|
||||
@@ -279,7 +284,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_battery_voltage",
|
||||
@@ -288,7 +293,7 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="storage_load_percentage",
|
||||
@@ -297,6 +302,6 @@ STORAGE_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=2,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
@@ -26,7 +27,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_energy_total",
|
||||
@@ -35,7 +36,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
@@ -45,7 +46,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
@@ -55,7 +56,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_voltage_input_1",
|
||||
@@ -63,7 +64,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="vpv1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_amperage_input_1",
|
||||
@@ -71,7 +72,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="ipv1",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_wattage_input_1",
|
||||
@@ -80,7 +81,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_energy_total_input_2",
|
||||
@@ -89,7 +90,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
@@ -99,7 +100,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_voltage_input_2",
|
||||
@@ -107,7 +108,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="vpv2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_amperage_input_2",
|
||||
@@ -115,7 +116,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="ipv2",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_wattage_input_2",
|
||||
@@ -124,7 +125,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_energy_total_input_3",
|
||||
@@ -133,7 +134,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
@@ -143,7 +144,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_voltage_input_3",
|
||||
@@ -151,7 +152,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="vpv3",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_amperage_input_3",
|
||||
@@ -159,7 +160,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="ipv3",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_wattage_input_3",
|
||||
@@ -168,7 +169,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_energy_total_input_4",
|
||||
@@ -177,7 +178,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
never_resets=True,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
@@ -187,7 +188,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_voltage_input_4",
|
||||
@@ -195,7 +196,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="vpv4",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_amperage_input_4",
|
||||
@@ -203,7 +204,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="ipv4",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_wattage_input_4",
|
||||
@@ -212,7 +213,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_solar_generation_today",
|
||||
@@ -221,7 +222,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_solar_generation_total",
|
||||
@@ -239,7 +240,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_reactive_voltage",
|
||||
@@ -247,7 +248,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="vacrs",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_frequency",
|
||||
@@ -255,7 +258,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="fac",
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_current_wattage",
|
||||
@@ -264,7 +269,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_temperature_1",
|
||||
@@ -272,7 +277,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="temp1",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_temperature_2",
|
||||
@@ -280,7 +287,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="temp2",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_temperature_3",
|
||||
@@ -288,7 +297,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="temp3",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_temperature_4",
|
||||
@@ -296,7 +307,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="temp4",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_temperature_5",
|
||||
@@ -304,7 +317,9 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
api_key="temp5",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_all_batteries_discharge_today",
|
||||
@@ -456,7 +471,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_pac_to_user_total",
|
||||
@@ -465,7 +480,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_pac_to_grid_total",
|
||||
@@ -474,7 +489,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_system_production_today",
|
||||
@@ -483,7 +498,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_system_production_total",
|
||||
@@ -493,7 +508,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
never_resets=True,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_self_consumption_today",
|
||||
@@ -502,7 +517,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_self_consumption_total",
|
||||
@@ -512,7 +527,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
never_resets=True,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_import_from_grid_today",
|
||||
@@ -521,7 +536,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_import_from_grid_total",
|
||||
@@ -531,7 +546,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
never_resets=True,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_batteries_charged_from_grid_today",
|
||||
@@ -540,7 +555,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_batteries_charged_from_grid_total",
|
||||
@@ -550,7 +565,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
never_resets=True,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_p_system",
|
||||
@@ -559,7 +574,7 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
GrowattSensorEntityDescription(
|
||||
key="tlx_p_self",
|
||||
@@ -568,6 +583,6 @@ TLX_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
precision=1,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -46,15 +46,20 @@ def _get_coordinator(
|
||||
|
||||
if not coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"No {device_type.upper()} devices with token authentication are configured. "
|
||||
f"Services require {device_type.upper()} devices with V1 API access."
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_devices_configured",
|
||||
translation_placeholders={"device_type": device_type.upper()},
|
||||
)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if not device_entry:
|
||||
raise ServiceValidationError(f"Device '{device_id}' not found")
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device_id": device_id},
|
||||
)
|
||||
|
||||
serial_number = None
|
||||
for identifier in device_entry.identifiers:
|
||||
@@ -63,11 +68,20 @@ def _get_coordinator(
|
||||
break
|
||||
|
||||
if not serial_number:
|
||||
raise ServiceValidationError(f"Device '{device_id}' is not a Growatt device")
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_growatt",
|
||||
translation_placeholders={"device_id": device_id},
|
||||
)
|
||||
|
||||
if serial_number not in coordinators:
|
||||
raise ServiceValidationError(
|
||||
f"{device_type.upper()} device '{serial_number}' not found or not configured for services"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_configured",
|
||||
translation_placeholders={
|
||||
"device_type": device_type.upper(),
|
||||
"serial_number": serial_number,
|
||||
},
|
||||
)
|
||||
|
||||
return coordinators[serial_number]
|
||||
@@ -78,13 +92,17 @@ def _parse_time_str(time_str: str, field_name: str) -> time:
|
||||
parts = time_str.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ServiceValidationError(
|
||||
f"{field_name} must be in HH:MM or HH:MM:SS format"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_time_format",
|
||||
translation_placeholders={"field_name": field_name},
|
||||
)
|
||||
try:
|
||||
return datetime.strptime(f"{parts[0]}:{parts[1]}", "%H:%M").time()
|
||||
except (ValueError, IndexError) as err:
|
||||
raise ServiceValidationError(
|
||||
f"{field_name} must be in HH:MM or HH:MM:SS format"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_time_format",
|
||||
translation_placeholders={"field_name": field_name},
|
||||
) from err
|
||||
|
||||
|
||||
@@ -103,7 +121,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
|
||||
if not 1 <= segment_id <= 9:
|
||||
raise ServiceValidationError(
|
||||
f"segment_id must be between 1 and 9, got {segment_id}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_segment_id",
|
||||
translation_placeholders={"segment_id": str(segment_id)},
|
||||
)
|
||||
|
||||
valid_modes = {
|
||||
@@ -113,7 +133,12 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
}
|
||||
if batt_mode_str not in valid_modes:
|
||||
raise ServiceValidationError(
|
||||
f"batt_mode must be one of {list(valid_modes.keys())}, got '{batt_mode_str}'"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_batt_mode",
|
||||
translation_placeholders={
|
||||
"batt_mode": batt_mode_str,
|
||||
"allowed_modes": ", ".join(valid_modes),
|
||||
},
|
||||
)
|
||||
batt_mode: int = valid_modes[batt_mode_str]
|
||||
|
||||
@@ -151,11 +176,15 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
|
||||
if not 0 <= charge_power <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"charge_power must be between 0 and 100, got {charge_power}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_charge_power",
|
||||
translation_placeholders={"value": str(charge_power)},
|
||||
)
|
||||
if not 0 <= charge_stop_soc <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"charge_stop_soc must be between 0 and 100, got {charge_stop_soc}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_charge_stop_soc",
|
||||
translation_placeholders={"value": str(charge_stop_soc)},
|
||||
)
|
||||
|
||||
periods = []
|
||||
@@ -193,11 +222,15 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
|
||||
if not 0 <= discharge_power <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"discharge_power must be between 0 and 100, got {discharge_power}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_discharge_power",
|
||||
translation_placeholders={"value": str(discharge_power)},
|
||||
)
|
||||
if not 0 <= discharge_stop_soc <= 100:
|
||||
raise ServiceValidationError(
|
||||
f"discharge_stop_soc must be between 0 and 100, got {discharge_stop_soc}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_discharge_stop_soc",
|
||||
translation_placeholders={"value": str(discharge_stop_soc)},
|
||||
)
|
||||
|
||||
periods = []
|
||||
|
||||
@@ -574,6 +574,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error": {
|
||||
"message": "Growatt API error: {error}"
|
||||
},
|
||||
"device_not_configured": {
|
||||
"message": "{device_type} device {serial_number} is not configured for services."
|
||||
},
|
||||
"device_not_found": {
|
||||
"message": "Device {device_id} not found in the device registry."
|
||||
},
|
||||
"device_not_growatt": {
|
||||
"message": "Device {device_id} is not a Growatt device."
|
||||
},
|
||||
"invalid_batt_mode": {
|
||||
"message": "{batt_mode} is not a valid battery mode. Allowed values: {allowed_modes}."
|
||||
},
|
||||
"invalid_charge_power": {
|
||||
"message": "charge_power must be between 0 and 100, got {value}."
|
||||
},
|
||||
"invalid_charge_stop_soc": {
|
||||
"message": "charge_stop_soc must be between 0 and 100, got {value}."
|
||||
},
|
||||
"invalid_discharge_power": {
|
||||
"message": "discharge_power must be between 0 and 100, got {value}."
|
||||
},
|
||||
"invalid_discharge_stop_soc": {
|
||||
"message": "discharge_stop_soc must be between 0 and 100, got {value}."
|
||||
},
|
||||
"invalid_segment_id": {
|
||||
"message": "segment_id must be between 1 and 9, got {segment_id}."
|
||||
},
|
||||
"invalid_time_format": {
|
||||
"message": "{field_name} must be in HH:MM or HH:MM:SS format."
|
||||
},
|
||||
"no_devices_configured": {
|
||||
"message": "No {device_type} devices with token authentication are configured. Actions require {device_type} devices with V1 API access."
|
||||
},
|
||||
"token_auth_required": {
|
||||
"message": "This action requires token authentication (V1 API)."
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"batt_mode": {
|
||||
"options": {
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GrowattConfigEntry, GrowattCoordinator
|
||||
from .sensor.sensor_entity_description import GrowattRequiredKeysMixin
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,9 +27,10 @@ PARALLEL_UPDATES = (
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GrowattSwitchEntityDescription(SwitchEntityDescription, GrowattRequiredKeysMixin):
|
||||
class GrowattSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Describes Growatt switch entity."""
|
||||
|
||||
api_key: str
|
||||
write_key: str | None = None # Parameter ID for writing (if different from api_key)
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
|
||||
identifiers={(DOMAIN, coordinator.device_id)},
|
||||
manufacturer="Growatt",
|
||||
name=coordinator.device_id,
|
||||
serial_number=coordinator.device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -124,7 +125,11 @@ class GrowattSwitch(CoordinatorEntity[GrowattCoordinator], SwitchEntity):
|
||||
api_value,
|
||||
)
|
||||
except GrowattV1ApiError as e:
|
||||
raise HomeAssistantError(f"Error while setting switch state: {e}") from e
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": str(e)},
|
||||
) from e
|
||||
|
||||
# If no exception was raised, the write was successful
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -119,7 +119,6 @@ from .coordinator import (
|
||||
get_core_stats,
|
||||
get_host_info,
|
||||
get_info,
|
||||
get_issues_info,
|
||||
get_network_info,
|
||||
get_os_info,
|
||||
get_store,
|
||||
@@ -158,7 +157,6 @@ __all__ = [
|
||||
"get_core_stats",
|
||||
"get_host_info",
|
||||
"get_info",
|
||||
"get_issues_info",
|
||||
"get_network_info",
|
||||
"get_os_info",
|
||||
"get_store",
|
||||
|
||||
@@ -132,6 +132,7 @@ ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
|
||||
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
|
||||
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
|
||||
ISSUE_KEY_ADDON_DEPRECATED_ARCH = "issue_addon_deprecated_arch_addon"
|
||||
|
||||
ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed"
|
||||
|
||||
@@ -172,6 +173,7 @@ EXTRA_PLACEHOLDERS = {
|
||||
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
|
||||
},
|
||||
ISSUE_KEY_ADDON_DEPRECATED: HELP_URLS,
|
||||
ISSUE_KEY_ADDON_DEPRECATED_ARCH: HELP_URLS,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ from .const import (
|
||||
EVENT_SUPPORTED_CHANGED,
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
@@ -90,6 +91,8 @@ ISSUE_KEYS_FOR_REPAIRS = {
|
||||
"issue_system_disk_lifetime",
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
|
||||
"issue_system_ntp_sync_failed",
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -253,9 +256,10 @@ class SupervisorIssues:
|
||||
def add_issue(self, issue: Issue) -> None:
|
||||
"""Add or update an issue in the list. Create or update a repair if necessary."""
|
||||
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
|
||||
placeholders: dict[str, str] = {}
|
||||
if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS:
|
||||
placeholders |= EXTRA_PLACEHOLDERS[issue.key]
|
||||
placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[issue.key].copy()
|
||||
else:
|
||||
placeholders = {}
|
||||
|
||||
if issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.4.1"],
|
||||
"requirements": ["aiohasupervisor==0.4.2"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_list, get_issues_info
|
||||
from . import get_addons_list
|
||||
from .const import (
|
||||
ATTR_SLUG,
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DEPRECATED,
|
||||
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
@@ -30,6 +31,7 @@ from .const import (
|
||||
PLACEHOLDER_KEY_COMPONENTS,
|
||||
PLACEHOLDER_KEY_REFERENCE,
|
||||
)
|
||||
from .coordinator import get_issues_info
|
||||
from .handler import get_supervisor_client
|
||||
from .issues import Issue, Suggestion
|
||||
|
||||
@@ -64,11 +66,16 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
@property
|
||||
def description_placeholders(self) -> dict[str, str] | None:
|
||||
"""Get description placeholders for steps."""
|
||||
placeholders = {}
|
||||
if self.issue:
|
||||
placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {})
|
||||
if self.issue.reference:
|
||||
placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference}
|
||||
if not self.issue:
|
||||
return None
|
||||
|
||||
if self.issue.key in EXTRA_PLACEHOLDERS:
|
||||
placeholders: dict[str, str] = EXTRA_PLACEHOLDERS[self.issue.key].copy()
|
||||
else:
|
||||
placeholders = {}
|
||||
|
||||
if self.issue.reference:
|
||||
placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference}
|
||||
|
||||
return placeholders or None
|
||||
|
||||
@@ -232,6 +239,7 @@ async def async_create_fix_flow(
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
|
||||
}:
|
||||
return AddonIssueRepairFlow(hass, issue_id)
|
||||
|
||||
|
||||
@@ -85,6 +85,19 @@
|
||||
},
|
||||
"title": "Installed app is deprecated"
|
||||
},
|
||||
"issue_addon_deprecated_arch_addon": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"apply_suggestion_fail": "Could not uninstall the app. Check the Supervisor logs for more details."
|
||||
},
|
||||
"step": {
|
||||
"addon_execute_remove": {
|
||||
"description": "App {addon} only supports architectures and/or machines which are no longer supported by Home Assistant. It will stop working in a future release.\n\nSelecting **Submit** will uninstall this deprecated app. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Installed app is built for unsupported architectures and/or machines"
|
||||
},
|
||||
"issue_addon_detached_addon_missing": {
|
||||
"description": "Repository for app {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [app's documentation]({addon_url}) for installation instructions and add the repository to the store.",
|
||||
"title": "Missing repository for an installed app"
|
||||
@@ -164,6 +177,19 @@
|
||||
},
|
||||
"title": "Multiple data disks detected"
|
||||
},
|
||||
"issue_system_ntp_sync_failed": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"apply_suggestion_fail": "Could not re-enable NTP. Check the Supervisor logs for more details."
|
||||
},
|
||||
"step": {
|
||||
"system_enable_ntp": {
|
||||
"description": "The device could not contact its configured time servers (NTP). Using a secondary online time check, we detected that the system clock was more than 1 hour incorrect. The time has been corrected and the NTP service was temporarily disabled so the correction could be applied. To keep the system time accurate, we recommend fixing the issue preventing access to the NTP servers.\n\nCheck the **Host logs** to investigate why NTP servers could not be reached. Once resolved, select **Submit** to re-enable the NTP service."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Time synchronization issue detected"
|
||||
},
|
||||
"issue_system_reboot_required": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
|
||||
@@ -117,13 +117,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
|
||||
# Map raw event type names to friendly names using SENSOR_MAP
|
||||
mapped_events: dict[str, list[int]] = {}
|
||||
for event_type, channels in nvr_events.items():
|
||||
friendly_name = SENSOR_MAP.get(event_type.lower(), event_type)
|
||||
event_key = event_type.lower()
|
||||
# Skip videoloss - used as watchdog by pyhik, not a real sensor
|
||||
if event_key == "videoloss":
|
||||
continue
|
||||
friendly_name = SENSOR_MAP.get(event_key)
|
||||
if friendly_name is None:
|
||||
_LOGGER.debug("Skipping unmapped event type: %s", event_type)
|
||||
continue
|
||||
if friendly_name in mapped_events:
|
||||
mapped_events[friendly_name].extend(channels)
|
||||
else:
|
||||
mapped_events[friendly_name] = list(channels)
|
||||
_LOGGER.debug("Mapped NVR events: %s", mapped_events)
|
||||
camera.inject_events(mapped_events)
|
||||
if mapped_events:
|
||||
camera.inject_events(mapped_events)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"No event triggers returned from %s. "
|
||||
|
||||
@@ -23,6 +23,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.32.0"],
|
||||
"requirements": ["aiohomeconnect==0.33.0"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -119,6 +119,10 @@ set_program_and_options:
|
||||
- cooking_common_program_hood_automatic
|
||||
- cooking_common_program_hood_venting
|
||||
- cooking_common_program_hood_delayed_shut_off
|
||||
- cooking_oven_program_heating_mode_3_d_heating
|
||||
- cooking_oven_program_heating_mode_air_fry
|
||||
- cooking_oven_program_heating_mode_grill_large_area
|
||||
- cooking_oven_program_heating_mode_grill_small_area
|
||||
- cooking_oven_program_heating_mode_pre_heating
|
||||
- cooking_oven_program_heating_mode_hot_air
|
||||
- cooking_oven_program_heating_mode_hot_air_eco
|
||||
|
||||
@@ -260,12 +260,16 @@
|
||||
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
|
||||
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
|
||||
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
|
||||
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
|
||||
"cooking_oven_program_heating_mode_grill_large_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_large_area%]",
|
||||
"cooking_oven_program_heating_mode_grill_small_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_small_area%]",
|
||||
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
|
||||
@@ -616,12 +620,16 @@
|
||||
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
|
||||
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
"cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
|
||||
"cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
|
||||
"cooking_oven_program_heating_mode_dough_proving": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_dough_proving%]",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
|
||||
"cooking_oven_program_heating_mode_grill_large_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_large_area%]",
|
||||
"cooking_oven_program_heating_mode_grill_small_area": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_grill_small_area%]",
|
||||
"cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
|
||||
"cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
|
||||
@@ -1621,12 +1629,16 @@
|
||||
"cooking_common_program_hood_automatic": "Automatic",
|
||||
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
|
||||
"cooking_common_program_hood_venting": "Venting",
|
||||
"cooking_oven_program_heating_mode_3_d_heating": "3D heating",
|
||||
"cooking_oven_program_heating_mode_air_fry": "Air fry",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "Bread baking",
|
||||
"cooking_oven_program_heating_mode_defrost": "Defrost",
|
||||
"cooking_oven_program_heating_mode_desiccation": "Desiccation",
|
||||
"cooking_oven_program_heating_mode_dough_proving": "Dough proving",
|
||||
"cooking_oven_program_heating_mode_frozen_heatup_special": "Special heat-up for frozen products",
|
||||
"cooking_oven_program_heating_mode_grill_large_area": "Grill (large area)",
|
||||
"cooking_oven_program_heating_mode_grill_small_area": "Grill (small area)",
|
||||
"cooking_oven_program_heating_mode_hot_air": "Hot air",
|
||||
"cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
|
||||
"cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.6.0"]
|
||||
"requirements": ["homematicip==2.7.0"]
|
||||
}
|
||||
|
||||
@@ -72,13 +72,6 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
|
||||
return HVACMode.HEAT
|
||||
return HVACMode.OFF
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return nice icon for heater."""
|
||||
if self.hvac_mode == HVACMode.HEAT:
|
||||
return "mdi:radiator"
|
||||
return "mdi:radiator-off"
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int | None:
|
||||
"""Return the current temperature."""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -45,8 +46,6 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
await huum.status()
|
||||
except Forbidden, NotAuthenticated:
|
||||
# Most likely Forbidden as that is what is returned from `.status()` with bad creds
|
||||
_LOGGER.error("Could not log in to Huum with given credentials")
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown error")
|
||||
@@ -59,3 +58,48 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
huum = Huum(
|
||||
reauth_entry.data[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
try:
|
||||
await huum.status()
|
||||
except Forbidden, NotAuthenticated:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown error")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
"username": reauth_entry.data[CONF_USERNAME],
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -12,8 +12,9 @@ from huum.schemas import HuumStatusResponse
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -54,7 +55,6 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
|
||||
try:
|
||||
return await self.huum.status()
|
||||
except (Forbidden, NotAuthenticated) as err:
|
||||
_LOGGER.error("Could not log in to Huum with given credentials")
|
||||
raise UpdateFailed(
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Could not log in to Huum with given credentials"
|
||||
) from err
|
||||
|
||||
@@ -7,11 +7,7 @@ rules:
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Remove _LOGGER.error call from config_flow.py — the error
|
||||
message is redundant with the errors dict entry.
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
@@ -40,13 +36,9 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Remove _LOGGER.error from coordinator.py — the message is already
|
||||
passed to UpdateFailed, so logging it separately is redundant.
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
@@ -74,11 +66,7 @@ rules:
|
||||
comment: All entities are core functionality.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: done
|
||||
comment: |
|
||||
PLANNED: Remove the icon property from climate.py — entities should not set
|
||||
custom icons. Use HA defaults or icon translations instead.
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -9,6 +10,16 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::huum::config::step::user::data_description::password%]"
|
||||
},
|
||||
"description": "The authentication for {username} is no longer valid. Please enter the current password.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user