mirror of
https://github.com/home-assistant/core.git
synced 2026-02-24 19:21:19 +01:00
Compare commits
133 Commits
infrared
...
update-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df4152d4e | ||
|
|
e6ed0b5d14 | ||
|
|
7adfb0a40b | ||
|
|
b4705e4a45 | ||
|
|
a0176d18cf | ||
|
|
5543107f6c | ||
|
|
6dc8840932 | ||
|
|
76902aa7fa | ||
|
|
07b9877f64 | ||
|
|
40e2f79e60 | ||
|
|
aa707fcf41 | ||
|
|
4b53bc243d | ||
|
|
220e94d029 | ||
|
|
b1f943ccda | ||
|
|
e37d84049a | ||
|
|
209473e376 | ||
|
|
334c3af448 | ||
|
|
5560139d24 | ||
|
|
d4dec5d1d3 | ||
|
|
6cb63a60bc | ||
|
|
991301e79e | ||
|
|
06e2b4633a | ||
|
|
048d8d217c | ||
|
|
3693bc5878 | ||
|
|
af9ea5ea7a | ||
|
|
977d29956b | ||
|
|
fc9bdb3cb1 | ||
|
|
bb1956c738 | ||
|
|
9212279c2c | ||
|
|
7e162cfda2 | ||
|
|
5611b4564f | ||
|
|
1a16674f86 | ||
|
|
bae4de3753 | ||
|
|
8f2bfa1bb0 | ||
|
|
fb118ed516 | ||
|
|
bea84151b1 | ||
|
|
d581d65c8b | ||
|
|
bc1837d09d | ||
|
|
9cc3c850aa | ||
|
|
8927960fca | ||
|
|
49b8232260 | ||
|
|
1d5e8a9e5a | ||
|
|
501e095578 | ||
|
|
dc5eab6810 | ||
|
|
9c640fe0fa | ||
|
|
62145e5f9e | ||
|
|
c0fc414bb9 | ||
|
|
69411a05ff | ||
|
|
06c9ec861d | ||
|
|
946df1755f | ||
|
|
d0678e0641 | ||
|
|
ec56f183da | ||
|
|
033005e0de | ||
|
|
91f9f5a826 | ||
|
|
ac4fcab827 | ||
|
|
d0eea77178 | ||
|
|
fb38fa3844 | ||
|
|
440efb953e | ||
|
|
7ce47cca0d | ||
|
|
a5f607bb91 | ||
|
|
b03043aa6f | ||
|
|
0f3c7ca277 | ||
|
|
3abf7c22f3 | ||
|
|
292e1de126 | ||
|
|
2d776a8193 | ||
|
|
039bbbb48c | ||
|
|
ad5565df95 | ||
|
|
3e6bc29a6a | ||
|
|
ec8067a5a8 | ||
|
|
6f47716d0a | ||
|
|
efba5c6bcc | ||
|
|
d10e78079f | ||
|
|
6d4581580f | ||
|
|
0d9a41a540 | ||
|
|
cd69e6db73 | ||
|
|
1320367d0d | ||
|
|
dfa4698887 | ||
|
|
b426115de7 | ||
|
|
fb79fa37f8 | ||
|
|
6a5f7bf424 | ||
|
|
142ca6dec1 | ||
|
|
0f986c24d0 | ||
|
|
01f2b7b6f6 | ||
|
|
b9469027f5 | ||
|
|
fbb94af748 | ||
|
|
148bdf6e3a | ||
|
|
91999f8871 | ||
|
|
aecca4eb99 | ||
|
|
bf8aa49bae | ||
|
|
4423425683 | ||
|
|
44202da53d | ||
|
|
9f7dfb72c4 | ||
|
|
de07a69e4f | ||
|
|
bbf4c38115 | ||
|
|
e1bb5d52ef | ||
|
|
eb64b6bdee | ||
|
|
ecb288b735 | ||
|
|
a419c9c420 | ||
|
|
dd29133324 | ||
|
|
90f22ea516 | ||
|
|
9db1428265 | ||
|
|
a696b05b0d | ||
|
|
77ddb63b73 | ||
|
|
4180a6e176 | ||
|
|
6d74c912d2 | ||
|
|
8a01dfcc00 | ||
|
|
9722898dc6 | ||
|
|
7438c71fcb | ||
|
|
0b5e55b923 | ||
|
|
61ed959e8e | ||
|
|
3989532465 | ||
|
|
28027ddca4 | ||
|
|
fe0d7b3cca | ||
|
|
0dcc4e9527 | ||
|
|
b13b189703 | ||
|
|
150829f599 | ||
|
|
57dd9d9c23 | ||
|
|
e2056cb12c | ||
|
|
fa2c8992cf | ||
|
|
ddf5c7fe3a | ||
|
|
7034ed6d3f | ||
|
|
9015b53c1b | ||
|
|
1cfa6561f7 | ||
|
|
eead02dcca | ||
|
|
456e51a221 | ||
|
|
5d984ce186 | ||
|
|
61f45489ac | ||
|
|
f72c643b38 | ||
|
|
27bc26e886 | ||
|
|
0e9f03cbc1 | ||
|
|
9480c33fb0 | ||
|
|
3e6b8663e8 | ||
|
|
1c69a83793 |
@@ -34,7 +34,6 @@ base_platforms: &base_platforms
|
||||
- homeassistant/components/humidifier/**
|
||||
- homeassistant/components/image/**
|
||||
- homeassistant/components/image_processing/**
|
||||
- homeassistant/components/infrared/**
|
||||
- homeassistant/components/lawn_mower/**
|
||||
- homeassistant/components/light/**
|
||||
- homeassistant/components/lock/**
|
||||
|
||||
579
.github/workflows/builder.yml
vendored
579
.github/workflows/builder.yml
vendored
@@ -57,10 +57,10 @@ jobs:
|
||||
with:
|
||||
type: ${{ env.BUILD_TYPE }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
ignore-dev: true
|
||||
# - name: Verify version
|
||||
# uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# ignore-dev: true
|
||||
|
||||
- name: Fail if translations files are checked in
|
||||
run: |
|
||||
@@ -272,7 +272,7 @@ jobs:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
@@ -294,6 +294,21 @@ jobs:
|
||||
- raspberrypi5-64
|
||||
- yellow
|
||||
- green
|
||||
include:
|
||||
# Default: aarch64 on native ARM runner
|
||||
- arch: aarch64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
# Overrides for amd64 machines
|
||||
- machine: generic-x86-64
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
- machine: qemux86-64
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
|
||||
- machine: intel-nuc
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -321,286 +336,288 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@21bc64d76dad7a5184c67826aab41c6b6f89023a # 2025.11.0
|
||||
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
|
||||
with:
|
||||
image: ${{ matrix.arch }}
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--test \
|
||||
--target /data/machine \
|
||||
--cosign \
|
||||
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
|
||||
|
||||
publish_ha:
|
||||
name: Publish version files
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_machine"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize git
|
||||
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
name: ${{ secrets.GIT_NAME }}
|
||||
email: ${{ secrets.GIT_EMAIL }}
|
||||
token: ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
- name: Update version file
|
||||
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
key: "homeassistant[]"
|
||||
key-description: "Home Assistant Core"
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
channel: ${{ needs.init.outputs.channel }}
|
||||
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
|
||||
- name: Update version file (stable -> beta)
|
||||
if: needs.init.outputs.channel == 'stable'
|
||||
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
with:
|
||||
key: "homeassistant[]"
|
||||
key-description: "Home Assistant Core"
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
channel: beta
|
||||
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
|
||||
publish_container:
|
||||
name: Publish meta container for ${{ matrix.registry }}
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify architecture image signatures
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Verifying ${arch} image signature..."
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
echo "✓ All images verified successfully"
|
||||
|
||||
# Generate all Docker tags based on version string
|
||||
# Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
|
||||
# Examples:
|
||||
# 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
|
||||
# 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
tags: |
|
||||
type=raw,value=${{ needs.init.outputs.version }},priority=9999
|
||||
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
# Use imagetools to copy image blobs directly between registries
|
||||
# This preserves provenance/attestations and seems to be much faster than pull/push
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
for arch in $ARCHS; do
|
||||
echo "Copying ${arch} image to DockerHub..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx imagetools create \
|
||||
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
|
||||
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
sleep 10
|
||||
if [ "${attempt}" -eq 3 ]; then
|
||||
echo "Failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
|
||||
done
|
||||
|
||||
- name: Create and push multi-arch manifests
|
||||
shell: bash
|
||||
env:
|
||||
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
REGISTRY: ${{ matrix.registry }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
# Build list of architecture images dynamically
|
||||
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
ARCH_IMAGES=()
|
||||
for arch in $ARCHS; do
|
||||
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
|
||||
done
|
||||
|
||||
# Build list of all tags for single manifest creation
|
||||
# Note: Using sep-tags=',' in metadata-action for easier parsing
|
||||
TAG_ARGS=()
|
||||
IFS=',' read -ra TAGS <<< "${META_TAGS}"
|
||||
for tag in "${TAGS[@]}"; do
|
||||
TAG_ARGS+=("--tag" "${tag}")
|
||||
done
|
||||
|
||||
# Create manifest with ALL tags in a single operation (much faster!)
|
||||
echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
|
||||
docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
|
||||
|
||||
# Sign each tag separately (signing requires individual tag names)
|
||||
echo "Signing all tags..."
|
||||
for tag in "${TAGS[@]}"; do
|
||||
echo "Signing ${tag}"
|
||||
cosign sign --yes "${tag}"
|
||||
done
|
||||
|
||||
echo "All manifests created and signed successfully"
|
||||
|
||||
build_python:
|
||||
name: Build PyPi package
|
||||
environment: ${{ needs.init.outputs.channel }}
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
id-token: write # For PyPI trusted publishing
|
||||
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
|
||||
- name: Build package
|
||||
shell: bash
|
||||
run: |
|
||||
# Remove dist, build, and homeassistant.egg-info
|
||||
# when build locally for testing!
|
||||
pip install build
|
||||
python -m build
|
||||
|
||||
- name: Upload package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
hassfest-image:
|
||||
name: Build and test hassfest image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
attestations: write # For build provenance attestation
|
||||
id-token: write # For build provenance attestation
|
||||
needs: ["init"]
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
env:
|
||||
HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
|
||||
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
load: true
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
|
||||
- name: Run hassfest against core
|
||||
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
|
||||
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
|
||||
|
||||
- name: Generate artifact attestation
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
# publish_ha:
|
||||
# name: Publish version files
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# needs: ["init", "build_machine"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read
|
||||
# steps:
|
||||
# - name: Checkout the repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
#
|
||||
# - name: Initialize git
|
||||
# uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# name: ${{ secrets.GIT_NAME }}
|
||||
# email: ${{ secrets.GIT_EMAIL }}
|
||||
# token: ${{ secrets.GIT_TOKEN }}
|
||||
#
|
||||
# - name: Update version file
|
||||
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# key: "homeassistant[]"
|
||||
# key-description: "Home Assistant Core"
|
||||
# version: ${{ needs.init.outputs.version }}
|
||||
# channel: ${{ needs.init.outputs.channel }}
|
||||
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
#
|
||||
# - name: Update version file (stable -> beta)
|
||||
# if: needs.init.outputs.channel == 'stable'
|
||||
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
|
||||
# with:
|
||||
# key: "homeassistant[]"
|
||||
# key-description: "Home Assistant Core"
|
||||
# version: ${{ needs.init.outputs.version }}
|
||||
# channel: beta
|
||||
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
|
||||
#
|
||||
# publish_container:
|
||||
# name: Publish meta container for ${{ matrix.registry }}
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# needs: ["init", "build_base"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# packages: write # To push to GHCR
|
||||
# id-token: write # For cosign signing
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
# steps:
|
||||
# - name: Install Cosign
|
||||
# uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
# with:
|
||||
# cosign-release: "v2.5.3"
|
||||
#
|
||||
# - name: Login to DockerHub
|
||||
# if: matrix.registry == 'docker.io/homeassistant'
|
||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
#
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
#
|
||||
# - name: Verify architecture image signatures
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# run: |
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# for arch in $ARCHS; do
|
||||
# echo "Verifying ${arch} image signature..."
|
||||
# cosign verify \
|
||||
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
# --certificate-identity-regexp https://github.com/home-assistant/core/.* \
|
||||
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
|
||||
# done
|
||||
# echo "✓ All images verified successfully"
|
||||
#
|
||||
# # Generate all Docker tags based on version string
|
||||
# # Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
|
||||
# # Examples:
|
||||
# # 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
|
||||
# # 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
|
||||
# # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
# - name: Generate Docker metadata
|
||||
# id: meta
|
||||
# uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
# with:
|
||||
# images: ${{ matrix.registry }}/home-assistant
|
||||
# sep-tags: ","
|
||||
# tags: |
|
||||
# type=raw,value=${{ needs.init.outputs.version }},priority=9999
|
||||
# type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
|
||||
# type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
# type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
# type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
#
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
|
||||
#
|
||||
# - name: Copy architecture images to DockerHub
|
||||
# if: matrix.registry == 'docker.io/homeassistant'
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# run: |
|
||||
# # Use imagetools to copy image blobs directly between registries
|
||||
# # This preserves provenance/attestations and seems to be much faster than pull/push
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# for arch in $ARCHS; do
|
||||
# echo "Copying ${arch} image to DockerHub..."
|
||||
# for attempt in 1 2 3; do
|
||||
# if docker buildx imagetools create \
|
||||
# --tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
|
||||
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
|
||||
# break
|
||||
# fi
|
||||
# echo "Attempt ${attempt} failed, retrying in 10 seconds..."
|
||||
# sleep 10
|
||||
# if [ "${attempt}" -eq 3 ]; then
|
||||
# echo "Failed after 3 attempts"
|
||||
# exit 1
|
||||
# fi
|
||||
# done
|
||||
# cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
|
||||
# done
|
||||
#
|
||||
# - name: Create and push multi-arch manifests
|
||||
# shell: bash
|
||||
# env:
|
||||
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
|
||||
# REGISTRY: ${{ matrix.registry }}
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# META_TAGS: ${{ steps.meta.outputs.tags }}
|
||||
# run: |
|
||||
# # Build list of architecture images dynamically
|
||||
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
|
||||
# ARCH_IMAGES=()
|
||||
# for arch in $ARCHS; do
|
||||
# ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
|
||||
# done
|
||||
#
|
||||
# # Build list of all tags for single manifest creation
|
||||
# # Note: Using sep-tags=',' in metadata-action for easier parsing
|
||||
# TAG_ARGS=()
|
||||
# IFS=',' read -ra TAGS <<< "${META_TAGS}"
|
||||
# for tag in "${TAGS[@]}"; do
|
||||
# TAG_ARGS+=("--tag" "${tag}")
|
||||
# done
|
||||
#
|
||||
# # Create manifest with ALL tags in a single operation (much faster!)
|
||||
# echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
|
||||
# docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
|
||||
#
|
||||
# # Sign each tag separately (signing requires individual tag names)
|
||||
# echo "Signing all tags..."
|
||||
# for tag in "${TAGS[@]}"; do
|
||||
# echo "Signing ${tag}"
|
||||
# cosign sign --yes "${tag}"
|
||||
# done
|
||||
#
|
||||
# echo "All manifests created and signed successfully"
|
||||
#
|
||||
# build_python:
|
||||
# name: Build PyPi package
|
||||
# environment: ${{ needs.init.outputs.channel }}
|
||||
# needs: ["init", "build_base"]
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# id-token: write # For PyPI trusted publishing
|
||||
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
|
||||
# steps:
|
||||
# - name: Checkout the repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
#
|
||||
# - name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
# with:
|
||||
# python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
#
|
||||
# - name: Download translations
|
||||
# uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
# with:
|
||||
# name: translations
|
||||
#
|
||||
# - name: Extract translations
|
||||
# run: |
|
||||
# tar xvf translations.tar.gz
|
||||
# rm translations.tar.gz
|
||||
#
|
||||
# - name: Build package
|
||||
# shell: bash
|
||||
# run: |
|
||||
# # Remove dist, build, and homeassistant.egg-info
|
||||
# # when build locally for testing!
|
||||
# pip install build
|
||||
# python -m build
|
||||
#
|
||||
# - name: Upload package to PyPI
|
||||
# uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
# with:
|
||||
# skip-existing: true
|
||||
#
|
||||
# hassfest-image:
|
||||
# name: Build and test hassfest image
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# packages: write # To push to GHCR
|
||||
# attestations: write # For build provenance attestation
|
||||
# id-token: write # For build provenance attestation
|
||||
# needs: ["init"]
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# env:
|
||||
# HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
|
||||
# HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
#
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
#
|
||||
# - name: Build Docker image
|
||||
# uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
# with:
|
||||
# context: . # So action will not pull the repository again
|
||||
# file: ./script/hassfest/docker/Dockerfile
|
||||
# load: true
|
||||
# tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
#
|
||||
# - name: Run hassfest against core
|
||||
# run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
|
||||
#
|
||||
# - name: Push Docker image
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# id: push
|
||||
# uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
# with:
|
||||
# context: . # So action will not pull the repository again
|
||||
# file: ./script/hassfest/docker/Dockerfile
|
||||
# push: true
|
||||
# tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
|
||||
#
|
||||
# - name: Generate artifact attestation
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
|
||||
# with:
|
||||
# subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
# subject-digest: ${{ steps.push.outputs.digest }}
|
||||
# push-to-registry: true
|
||||
|
||||
@@ -289,7 +289,6 @@ homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.inels.*
|
||||
homeassistant.components.infrared.*
|
||||
homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.input_text.*
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -555,8 +555,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
|
||||
/tests/components/fritzbox/ @mib1185 @flabbamann
|
||||
/homeassistant/components/fritzbox_callmonitor/ @cdce8p
|
||||
/tests/components/fritzbox_callmonitor/ @cdce8p
|
||||
/homeassistant/components/fronius/ @farmio
|
||||
/tests/components/fronius/ @farmio
|
||||
/homeassistant/components/frontend/ @home-assistant/frontend
|
||||
@@ -794,8 +792,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/inels/ @epdevlab
|
||||
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
|
||||
/tests/components/influxdb/ @mdegat01 @Robbie1221
|
||||
/homeassistant/components/infrared/ @home-assistant/core
|
||||
/tests/components/infrared/ @home-assistant/core
|
||||
/homeassistant/components/inkbird/ @bdraco
|
||||
/tests/components/inkbird/ @bdraco
|
||||
/homeassistant/components/input_boolean/ @home-assistant/core
|
||||
|
||||
@@ -34,11 +34,13 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import (
|
||||
DEFAULT_SSL,
|
||||
@@ -392,6 +394,18 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Automatically handle a DHCP discovered IP change."""
|
||||
ip_address = discovery_info.ip
|
||||
# python-airos defaults to upper for derived mac_address
|
||||
normalized_mac = format_mac(discovery_info.macaddress).upper()
|
||||
await self.async_set_unique_id(normalized_mac)
|
||||
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address})
|
||||
return self.async_abort(reason="unreachable")
|
||||
|
||||
async def async_step_discovery_no_devices(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Ubiquiti airOS",
|
||||
"codeowners": ["@CoMPaTech"],
|
||||
"config_flow": true,
|
||||
"dhcp": [{ "registered_devices": true }],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
config_entry_oauth2_flow,
|
||||
@@ -31,11 +33,27 @@ async def async_setup_entry(
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
client = AladdinConnectClient(
|
||||
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
|
||||
)
|
||||
|
||||
doors = await client.get_doors()
|
||||
try:
|
||||
doors = await client.get_doors()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
entry.runtime_data = {
|
||||
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)
|
||||
|
||||
@@ -11,6 +11,18 @@ API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
|
||||
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
|
||||
|
||||
|
||||
class AsyncConfigFlowAuth(Auth):
|
||||
"""Provide Aladdin Connect Genie authentication for config flow validation."""
|
||||
|
||||
def __init__(self, websession: ClientSession, access_token: str) -> None:
|
||||
"""Initialize Aladdin Connect Genie auth."""
|
||||
super().__init__(websession, API_URL, access_token, API_KEY)
|
||||
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return the access token."""
|
||||
return self.access_token
|
||||
|
||||
|
||||
class AsyncConfigEntryAuth(Auth):
|
||||
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
import jwt
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
|
||||
from .api import AsyncConfigFlowAuth
|
||||
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
|
||||
|
||||
|
||||
@@ -52,11 +54,25 @@ class OAuth2FlowHandler(
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
|
||||
"""Create an oauth config entry or update existing entry for reauth."""
|
||||
# Extract the user ID from the JWT token's 'sub' field
|
||||
token = jwt.decode(
|
||||
data["token"]["access_token"], options={"verify_signature": False}
|
||||
try:
|
||||
token = jwt.decode(
|
||||
data["token"]["access_token"], options={"verify_signature": False}
|
||||
)
|
||||
user_id = token["sub"]
|
||||
except jwt.DecodeError, KeyError:
|
||||
return self.async_abort(reason="oauth_error")
|
||||
|
||||
client = AladdinConnectClient(
|
||||
AsyncConfigFlowAuth(
|
||||
aiohttp_client.async_get_clientsession(self.hass),
|
||||
data["token"]["access_token"],
|
||||
)
|
||||
)
|
||||
user_id = token["sub"]
|
||||
try:
|
||||
await client.get_doors()
|
||||
except Exception: # noqa: BLE001
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
await self.async_set_unique_id(user_id)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
|
||||
@@ -7,39 +7,31 @@ rules:
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: todo
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register any service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-removal-instructions:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to external events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure:
|
||||
status: todo
|
||||
comment: Config flow does not currently test connection during setup.
|
||||
test-before-setup: todo
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-installation-parameters:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
status: exempt
|
||||
comment: Integration does not have an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
@@ -52,29 +44,17 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-examples:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-known-limitations:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-supported-functions:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-troubleshooting:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
docs-use-cases:
|
||||
status: todo
|
||||
comment: Documentation needs to be created.
|
||||
discovery: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration connects via the cloud and not locally.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
@@ -86,7 +66,7 @@ rules:
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: Stale devices can be done dynamically
|
||||
comment: We can automatically remove removed devices
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"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%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml.",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
|
||||
@@ -112,19 +112,12 @@ async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionD
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in (
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-opus-20240229",
|
||||
)
|
||||
if model_info.id != "claude-3-haiku-20240307"
|
||||
and model_info.id[-2:-1] != "-"
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
|
||||
@@ -37,8 +37,6 @@ DEFAULT = {
|
||||
MIN_THINKING_BUDGET = 1024
|
||||
|
||||
NON_THINKING_MODELS = [
|
||||
"claude-3-5", # Both sonnet and haiku
|
||||
"claude-3-opus",
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
@@ -51,7 +49,7 @@ NON_ADAPTIVE_THINKING_MODELS = [
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3",
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
|
||||
@@ -60,19 +58,13 @@ UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3",
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
"claude-3-opus",
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3-5-haiku",
|
||||
"claude-3-7-sonnet",
|
||||
"claude-3-5-sonnet",
|
||||
"claude-3-opus",
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.78.0"]
|
||||
"requirements": ["anthropic==0.83.0"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.selector import (
|
||||
)
|
||||
|
||||
from .config_flow import get_model_list
|
||||
from .const import CONF_CHAT_MODEL, DEFAULT, DEPRECATED_MODELS, DOMAIN
|
||||
from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AnthropicConfigEntry
|
||||
@@ -67,13 +67,23 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
self._model_list_cache[entry.entry_id] = model_list
|
||||
|
||||
if "opus" in model:
|
||||
suggested_model = "claude-opus-4-5"
|
||||
elif "haiku" in model:
|
||||
suggested_model = "claude-haiku-4-5"
|
||||
family = "claude-opus"
|
||||
elif "sonnet" in model:
|
||||
suggested_model = "claude-sonnet-4-5"
|
||||
family = "claude-sonnet"
|
||||
else:
|
||||
suggested_model = cast(str, DEFAULT[CONF_CHAT_MODEL])
|
||||
family = "claude-haiku"
|
||||
|
||||
suggested_model = next(
|
||||
(
|
||||
model_option["value"]
|
||||
for model_option in sorted(
|
||||
(m for m in model_list if family in m["value"]),
|
||||
key=lambda x: x["value"],
|
||||
reverse=True,
|
||||
)
|
||||
),
|
||||
vol.UNDEFINED,
|
||||
)
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import IO, Any, cast
|
||||
|
||||
import aiohttp
|
||||
from securetar import (
|
||||
InvalidPasswordError,
|
||||
SecureTarArchive,
|
||||
SecureTarError,
|
||||
SecureTarFile,
|
||||
@@ -165,7 +166,7 @@ def validate_password(path: Path, password: str | None) -> bool:
|
||||
):
|
||||
# If we can read the tar file, the password is correct
|
||||
return True
|
||||
except tarfile.ReadError, SecureTarReadError:
|
||||
except tarfile.ReadError, InvalidPasswordError, SecureTarReadError:
|
||||
LOGGER.debug("Invalid password")
|
||||
return False
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -192,13 +193,14 @@ def validate_password_stream(
|
||||
for obj in input_archive.tar:
|
||||
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
|
||||
continue
|
||||
with input_archive.extract_tar(obj) as decrypted:
|
||||
if decrypted.plaintext_size is None:
|
||||
raise UnsupportedSecureTarVersion
|
||||
try:
|
||||
try:
|
||||
with input_archive.extract_tar(obj) as decrypted:
|
||||
if decrypted.plaintext_size is None:
|
||||
raise UnsupportedSecureTarVersion
|
||||
decrypted.read(1) # Read a single byte to trigger the decryption
|
||||
except SecureTarReadError as err:
|
||||
raise IncorrectPassword from err
|
||||
except (InvalidPasswordError, SecureTarReadError) as err:
|
||||
raise IncorrectPassword from err
|
||||
else:
|
||||
return
|
||||
raise BackupEmpty
|
||||
|
||||
|
||||
@@ -29,8 +29,13 @@ if TYPE_CHECKING:
|
||||
|
||||
# Filter lists for optimized API calls - only fetch parameters we actually use
|
||||
# This significantly reduces response time (~0.2s per parameter saved)
|
||||
STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
|
||||
SENSOR_INCLUDE = ["current_temperature", "outside_temperature"]
|
||||
STATE_INCLUDE = [
|
||||
"current_temperature",
|
||||
"target_temperature",
|
||||
"hvac_mode",
|
||||
"hvac_action",
|
||||
]
|
||||
SENSOR_INCLUDE = ["current_temperature", "outside_temperature", "total_energy"]
|
||||
DHW_STATE_INCLUDE = [
|
||||
"operating_mode",
|
||||
"nominal_setpoint",
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.const import UnitOfEnergy, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -58,6 +58,19 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
|
||||
),
|
||||
exists_fn=lambda data: data.sensor.outside_temperature is not None,
|
||||
),
|
||||
BSBLanSensorEntityDescription(
|
||||
key="total_energy",
|
||||
translation_key="total_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda data: (
|
||||
data.sensor.total_energy.value
|
||||
if data.sensor.total_energy is not None
|
||||
else None
|
||||
),
|
||||
exists_fn=lambda data: data.sensor.total_energy is not None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
},
|
||||
"outside_temperature": {
|
||||
"name": "Outside temperature"
|
||||
},
|
||||
"total_energy": {
|
||||
"name": "Total energy"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,17 +8,24 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from deebot_client.capabilities import Capabilities, DeviceType
|
||||
from deebot_client.device import Device
|
||||
from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent
|
||||
from deebot_client.models import CleanAction, CleanMode, Room, State
|
||||
from deebot_client.events import (
|
||||
CachedMapInfoEvent,
|
||||
FanSpeedEvent,
|
||||
RoomsEvent,
|
||||
StateEvent,
|
||||
)
|
||||
from deebot_client.events.map import Map
|
||||
from deebot_client.models import CleanAction, CleanMode, State
|
||||
import sucks
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
Segment,
|
||||
StateVacuumEntity,
|
||||
StateVacuumEntityDescription,
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import slugify
|
||||
@@ -29,6 +36,7 @@ from .entity import EcovacsEntity, EcovacsLegacyEntity
|
||||
from .util import get_name_key
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_SEGMENTS_SEPARATOR = "_"
|
||||
|
||||
ATTR_ERROR = "error"
|
||||
|
||||
@@ -218,7 +226,8 @@ class EcovacsVacuum(
|
||||
"""Initialize the vacuum."""
|
||||
super().__init__(device, device.capabilities)
|
||||
|
||||
self._rooms: list[Room] = []
|
||||
self._room_event: RoomsEvent | None = None
|
||||
self._maps: dict[str, Map] = {}
|
||||
|
||||
if fan_speed := self._capability.fan_speed:
|
||||
self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED
|
||||
@@ -226,14 +235,13 @@ class EcovacsVacuum(
|
||||
get_name_key(level) for level in fan_speed.types
|
||||
]
|
||||
|
||||
if self._capability.map and self._capability.clean.action.area:
|
||||
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Set up the event listeners now that hass is ready."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
async def on_rooms(event: RoomsEvent) -> None:
|
||||
self._rooms = event.rooms
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def on_status(event: StateEvent) -> None:
|
||||
self._attr_activity = _STATE_TO_VACUUM_STATE[event.state]
|
||||
self.async_write_ha_state()
|
||||
@@ -249,8 +257,20 @@ class EcovacsVacuum(
|
||||
self._subscribe(self._capability.fan_speed.event, on_fan_speed)
|
||||
|
||||
if map_caps := self._capability.map:
|
||||
|
||||
async def on_rooms(event: RoomsEvent) -> None:
|
||||
self._room_event = event
|
||||
self._check_segments_changed()
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._subscribe(map_caps.rooms.event, on_rooms)
|
||||
|
||||
async def on_map_info(event: CachedMapInfoEvent) -> None:
|
||||
self._maps = {map_obj.id: map_obj for map_obj in event.maps}
|
||||
self._check_segments_changed()
|
||||
|
||||
self._subscribe(map_caps.cached_info.event, on_map_info)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
"""Return entity specific state attributes.
|
||||
@@ -259,7 +279,10 @@ class EcovacsVacuum(
|
||||
is lowercase snake_case.
|
||||
"""
|
||||
rooms: dict[str, Any] = {}
|
||||
for room in self._rooms:
|
||||
if self._room_event is None:
|
||||
return rooms
|
||||
|
||||
for room in self._room_event.rooms:
|
||||
# convert room name to snake_case to meet the convention
|
||||
room_name = slugify(room.name)
|
||||
room_values = rooms.get(room_name)
|
||||
@@ -374,3 +397,116 @@ class EcovacsVacuum(
|
||||
)
|
||||
|
||||
return await self._device.execute_command(position_commands[0])
|
||||
|
||||
@callback
|
||||
def _check_segments_changed(self) -> None:
|
||||
"""Check if segments have changed and create repair issue."""
|
||||
last_seen = self.last_seen_segments
|
||||
if last_seen is None:
|
||||
return
|
||||
|
||||
last_seen_ids = {seg.id for seg in last_seen}
|
||||
current_ids = {seg.id for seg in self._get_segments()}
|
||||
|
||||
if current_ids != last_seen_ids:
|
||||
self.async_create_segments_issue()
|
||||
|
||||
def _get_segments(self) -> list[Segment]:
|
||||
"""Get the segments that can be cleaned."""
|
||||
last_seen = self.last_seen_segments or []
|
||||
if self._room_event is None or not self._maps:
|
||||
# If we don't have the necessary information to determine segments, return the last
|
||||
# seen segments to avoid temporarily losing all segments until we get the necessary
|
||||
# information, which could cause unnecessary issues to be created
|
||||
return last_seen
|
||||
|
||||
map_id = self._room_event.map_id
|
||||
if (map_obj := self._maps.get(map_id)) is None:
|
||||
_LOGGER.warning("Map ID %s not found in available maps", map_id)
|
||||
return []
|
||||
|
||||
id_prefix = f"{map_id}{_SEGMENTS_SEPARATOR}"
|
||||
other_map_ids = {
|
||||
map_obj.id
|
||||
for map_obj in self._maps.values()
|
||||
if map_obj.id != self._room_event.map_id
|
||||
}
|
||||
# Include segments from the current map and any segments from other maps that were
|
||||
# previously seen, as we want to continue showing segments from other maps for
|
||||
# mapping purposes
|
||||
segments = [
|
||||
seg for seg in last_seen if _split_composite_id(seg.id)[0] in other_map_ids
|
||||
]
|
||||
segments.extend(
|
||||
Segment(
|
||||
id=f"{id_prefix}{room.id}",
|
||||
name=room.name,
|
||||
group=map_obj.name,
|
||||
)
|
||||
for room in self._room_event.rooms
|
||||
)
|
||||
return segments
|
||||
|
||||
async def async_get_segments(self) -> list[Segment]:
|
||||
"""Get the segments that can be cleaned."""
|
||||
return self._get_segments()
|
||||
|
||||
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
|
||||
"""Perform an area clean.
|
||||
|
||||
Only cleans segments from the currently selected map.
|
||||
"""
|
||||
if not self._maps:
|
||||
_LOGGER.warning("No map information available, cannot clean segments")
|
||||
return
|
||||
|
||||
valid_room_ids: list[int | float] = []
|
||||
for composite_id in segment_ids:
|
||||
map_id, segment_id = _split_composite_id(composite_id)
|
||||
if (map_obj := self._maps.get(map_id)) is None:
|
||||
_LOGGER.warning("Map ID %s not found in available maps", map_id)
|
||||
continue
|
||||
|
||||
if not map_obj.using:
|
||||
room_name = next(
|
||||
(
|
||||
segment.name
|
||||
for segment in self.last_seen_segments or []
|
||||
if segment.id == composite_id
|
||||
),
|
||||
"",
|
||||
)
|
||||
_LOGGER.warning(
|
||||
'Map "%s" is not currently selected, skipping segment "%s" (%s)',
|
||||
map_obj.name,
|
||||
room_name,
|
||||
segment_id,
|
||||
)
|
||||
continue
|
||||
|
||||
valid_room_ids.append(int(segment_id))
|
||||
|
||||
if not valid_room_ids:
|
||||
_LOGGER.warning(
|
||||
"No valid segments to clean after validation, skipping clean segments command"
|
||||
)
|
||||
return
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Supported feature is only added if clean.action.area is not None
|
||||
assert self._capability.clean.action.area is not None
|
||||
|
||||
await self._device.execute_command(
|
||||
self._capability.clean.action.area(
|
||||
CleanMode.SPOT_AREA,
|
||||
valid_room_ids,
|
||||
1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _split_composite_id(composite_id: str) -> tuple[str, str]:
|
||||
"""Split a composite ID into its components."""
|
||||
map_id, _, segment_id = composite_id.partition(_SEGMENTS_SEPARATOR)
|
||||
return map_id, segment_id
|
||||
|
||||
@@ -4,17 +4,23 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.components.usb import (
|
||||
human_readable_device_name,
|
||||
usb_unique_id_from_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
from homeassistant.const import ATTR_MANUFACTURER, CONF_DEVICE, CONF_NAME
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.service_info.usb import UsbServiceInfo
|
||||
|
||||
from . import dongle
|
||||
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER
|
||||
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER, MANUFACTURER
|
||||
|
||||
MANUAL_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -31,8 +37,48 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the EnOcean config flow."""
|
||||
self.dongle_path = None
|
||||
self.discovery_info = None
|
||||
self.data: dict[str, Any] = {}
|
||||
|
||||
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
|
||||
"""Handle usb discovery."""
|
||||
unique_id = usb_unique_id_from_service_info(discovery_info)
|
||||
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_DEVICE: discovery_info.device}
|
||||
)
|
||||
|
||||
discovery_info.device = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, discovery_info.device
|
||||
)
|
||||
|
||||
self.data[CONF_DEVICE] = discovery_info.device
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_NAME: human_readable_device_name(
|
||||
discovery_info.device,
|
||||
discovery_info.serial_number,
|
||||
discovery_info.manufacturer,
|
||||
discovery_info.description,
|
||||
discovery_info.vid,
|
||||
discovery_info.pid,
|
||||
)
|
||||
}
|
||||
return await self.async_step_usb_confirm()
|
||||
|
||||
async def async_step_usb_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle USB Discovery confirmation."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_manual({CONF_DEVICE: self.data[CONF_DEVICE]})
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="usb_confirm",
|
||||
description_placeholders={
|
||||
ATTR_MANUFACTURER: MANUFACTURER,
|
||||
CONF_DEVICE: self.data.get(CONF_DEVICE, ""),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import a yaml configuration."""
|
||||
@@ -104,4 +150,4 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def create_enocean_entry(self, user_input):
|
||||
"""Create an entry for the provided configuration."""
|
||||
return self.async_create_entry(title="EnOcean", data=user_input)
|
||||
return self.async_create_entry(title=MANUFACTURER, data=user_input)
|
||||
|
||||
@@ -6,6 +6,8 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "enocean"
|
||||
|
||||
MANUFACTURER = "EnOcean"
|
||||
|
||||
ERROR_INVALID_DONGLE_PATH = "invalid_dongle_path"
|
||||
|
||||
SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message"
|
||||
|
||||
@@ -3,10 +3,19 @@
|
||||
"name": "EnOcean",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/enocean",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["enocean"],
|
||||
"requirements": ["enocean==0.50"],
|
||||
"single_config_entry": true
|
||||
"single_config_entry": true,
|
||||
"usb": [
|
||||
{
|
||||
"description": "*usb 300*",
|
||||
"manufacturer": "*enocean*",
|
||||
"pid": "6001",
|
||||
"vid": "0403"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
"device": "[%key:component::enocean::config::step::detect::data_description::device%]"
|
||||
},
|
||||
"description": "Enter the path to your EnOcean USB dongle."
|
||||
},
|
||||
"usb_confirm": {
|
||||
"description": "{manufacturer} USB dongle detected at {device}. Do you want to set up this device?"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -300,16 +300,23 @@ class RuntimeEntryData:
|
||||
needed_platforms.add(Platform.BINARY_SENSOR)
|
||||
needed_platforms.add(Platform.SELECT)
|
||||
|
||||
needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos)
|
||||
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
|
||||
|
||||
# Make a dict of the EntityInfo by type and send
|
||||
# them to the listeners for each specific EntityInfo type
|
||||
info_types_to_platform = INFO_TYPE_TO_PLATFORM
|
||||
infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
|
||||
list
|
||||
)
|
||||
for info in infos:
|
||||
infos_by_type[type(info)].append(info)
|
||||
info_type = type(info)
|
||||
if platform := info_types_to_platform.get(info_type):
|
||||
needed_platforms.add(platform)
|
||||
infos_by_type[info_type].append(info)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Entity type %s is not supported in this version of Home Assistant",
|
||||
info_type,
|
||||
)
|
||||
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
|
||||
|
||||
for type_, callbacks in self.entity_info_callbacks.items():
|
||||
# If all entities for a type are removed, we
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.0.0",
|
||||
"aioesphomeapi==44.1.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.6.0"
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "fritzbox_callmonitor",
|
||||
"name": "FRITZ!Box Call Monitor",
|
||||
"codeowners": ["@cdce8p"],
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -1752,15 +1752,15 @@ class FanSpeedTrait(_Trait):
|
||||
"""Initialize a trait for a state."""
|
||||
super().__init__(hass, state, config)
|
||||
if state.domain == fan.DOMAIN:
|
||||
speed_count = min(
|
||||
FAN_SPEED_MAX_SPEED_COUNT,
|
||||
round(
|
||||
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
|
||||
),
|
||||
speed_count = round(
|
||||
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
|
||||
)
|
||||
self._ordered_speed = [
|
||||
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
|
||||
]
|
||||
if speed_count <= FAN_SPEED_MAX_SPEED_COUNT:
|
||||
self._ordered_speed = [
|
||||
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
|
||||
]
|
||||
else:
|
||||
self._ordered_speed = []
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features, device_class, _):
|
||||
@@ -1786,7 +1786,11 @@ class FanSpeedTrait(_Trait):
|
||||
result.update(
|
||||
{
|
||||
"reversible": reversible,
|
||||
"supportsFanSpeedPercent": True,
|
||||
# supportsFanSpeedPercent is mutually exclusive with
|
||||
# availableFanSpeeds, where supportsFanSpeedPercent takes
|
||||
# precedence. Report it only when step speeds are not
|
||||
# supported so Google renders a percent slider (1-100%).
|
||||
"supportsFanSpeedPercent": not self._ordered_speed,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1832,10 +1836,12 @@ class FanSpeedTrait(_Trait):
|
||||
|
||||
if domain == fan.DOMAIN:
|
||||
percent = attrs.get(fan.ATTR_PERCENTAGE) or 0
|
||||
response["currentFanSpeedPercent"] = percent
|
||||
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
|
||||
self._ordered_speed, percent
|
||||
)
|
||||
if self._ordered_speed:
|
||||
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
|
||||
self._ordered_speed, percent
|
||||
)
|
||||
else:
|
||||
response["currentFanSpeedPercent"] = percent
|
||||
|
||||
return response
|
||||
|
||||
@@ -1855,7 +1861,7 @@ class FanSpeedTrait(_Trait):
|
||||
)
|
||||
|
||||
if domain == fan.DOMAIN:
|
||||
if fan_speed := params.get("fanSpeed"):
|
||||
if self._ordered_speed and (fan_speed := params.get("fanSpeed")):
|
||||
fan_speed_percent = ordered_list_item_to_percentage(
|
||||
self._ordered_speed, fan_speed
|
||||
)
|
||||
|
||||
@@ -181,8 +181,7 @@ class HassIOIngress(HomeAssistantView):
|
||||
skip_auto_headers={hdrs.CONTENT_TYPE},
|
||||
) as result:
|
||||
headers = _response_header(result)
|
||||
content_length_int = 0
|
||||
content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED)
|
||||
|
||||
# Avoid parsing content_type in simple cases for better performance
|
||||
if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE):
|
||||
content_type: str = (maybe_content_type.partition(";"))[0].strip()
|
||||
@@ -190,17 +189,30 @@ class HassIOIngress(HomeAssistantView):
|
||||
# default value according to RFC 2616
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
# Empty body responses (304, 204, HEAD, etc.) should not be streamed,
|
||||
# otherwise aiohttp < 3.9.0 may generate an invalid "0\r\n\r\n" chunk
|
||||
# This also avoids setting content_type for empty responses.
|
||||
if must_be_empty_body(request.method, result.status):
|
||||
# If upstream contains content-type, preserve it (e.g. for HEAD requests)
|
||||
# Note: This still is omitting content-length. We can't simply forward
|
||||
# the upstream length since the proxy might change the body length
|
||||
# (e.g. due to compression).
|
||||
if maybe_content_type:
|
||||
headers[hdrs.CONTENT_TYPE] = content_type
|
||||
return web.Response(
|
||||
headers=headers,
|
||||
status=result.status,
|
||||
)
|
||||
|
||||
# Simple request
|
||||
if (empty_body := must_be_empty_body(result.method, result.status)) or (
|
||||
content_length_int = 0
|
||||
content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED)
|
||||
if (
|
||||
content_length is not UNDEFINED
|
||||
and (content_length_int := int(content_length))
|
||||
<= MAX_SIMPLE_RESPONSE_SIZE
|
||||
):
|
||||
# Return Response
|
||||
if empty_body:
|
||||
body = None
|
||||
else:
|
||||
body = await result.read()
|
||||
body = await result.read()
|
||||
simple_response = web.Response(
|
||||
headers=headers,
|
||||
status=result.status,
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhik"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyHik==0.4.2"]
|
||||
}
|
||||
|
||||
75
homeassistant/components/hikvision/quality_scale.yaml
Normal file
75
homeassistant/components/hikvision/quality_scale.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration uses local_push and does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: todo
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has no configuration parameters.
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -11,7 +11,12 @@ from pyHomee import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_USER,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
@@ -113,7 +118,22 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if discovery_info.ip_address.version == 6:
|
||||
return self.async_abort(reason="ipv6_address")
|
||||
|
||||
await self.async_set_unique_id(self._name)
|
||||
# If an already configured homee reports with a second IP, abort.
|
||||
existing_entry = await self.async_set_unique_id(self._name)
|
||||
if (
|
||||
existing_entry
|
||||
and existing_entry.state == ConfigEntryState.LOADED
|
||||
and existing_entry.runtime_data.connected
|
||||
and existing_entry.data[CONF_HOST] != self._host
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"Aborting config flow for discovered homee with IP %s "
|
||||
"since it is already configured at IP %s",
|
||||
self._host,
|
||||
existing_entry.data[CONF_HOST],
|
||||
)
|
||||
return self.async_abort(reason="2nd_ip_address")
|
||||
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
|
||||
|
||||
# Cause an auth-error to see if homee is reachable.
|
||||
|
||||
@@ -20,6 +20,7 @@ PARALLEL_UPDATES = 0
|
||||
|
||||
REMOTE_PROFILES = [
|
||||
NodeProfile.REMOTE,
|
||||
NodeProfile.ONE_BUTTON_REMOTE,
|
||||
NodeProfile.TWO_BUTTON_REMOTE,
|
||||
NodeProfile.THREE_BUTTON_REMOTE,
|
||||
NodeProfile.FOUR_BUTTON_REMOTE,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"2nd_ip_address": "Your homee is already connected using another IP address",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:
|
||||
|
||||
67
homeassistant/components/homevolt/entity.py
Normal file
67
homeassistant/components/homevolt/entity.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Shared entity helpers for Homevolt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError, HomevoltError
|
||||
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import HomevoltDataUpdateCoordinator
|
||||
|
||||
|
||||
class HomevoltEntity(CoordinatorEntity[HomevoltDataUpdateCoordinator]):
|
||||
"""Base Homevolt entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, coordinator: HomevoltDataUpdateCoordinator, device_identifier: str
|
||||
) -> None:
|
||||
"""Initialize the Homevolt entity."""
|
||||
super().__init__(coordinator)
|
||||
device_id = coordinator.data.unique_id
|
||||
device_metadata = coordinator.data.device_metadata.get(device_identifier)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{device_id}_{device_identifier}")},
|
||||
configuration_url=coordinator.client.base_url,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=device_metadata.model if device_metadata else None,
|
||||
name=device_metadata.name if device_metadata else None,
|
||||
)
|
||||
|
||||
|
||||
def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
|
||||
func: Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate Homevolt calls to handle exceptions."""
|
||||
|
||||
async def handler(
|
||||
self: _HomevoltEntityT, *args: _P.args, **kwargs: _P.kwargs
|
||||
) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except HomevoltAuthenticationError as error:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from error
|
||||
except HomevoltConnectionError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
except HomevoltError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_error",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
|
||||
return handler
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["homevolt==0.4.4"],
|
||||
"requirements": ["homevolt==0.5.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "homevolt*",
|
||||
|
||||
@@ -22,13 +22,11 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
)
|
||||
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, MANUFACTURER
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
from .entity import HomevoltEntity
|
||||
|
||||
PARALLEL_UPDATES = 0 # Coordinator-based updates
|
||||
|
||||
@@ -309,11 +307,10 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEntity):
|
||||
class HomevoltSensor(HomevoltEntity, SensorEntity):
|
||||
"""Representation of a Homevolt sensor."""
|
||||
|
||||
entity_description: SensorEntityDescription
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -322,24 +319,12 @@ class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEnt
|
||||
sensor_key: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
unique_id = coordinator.data.unique_id
|
||||
self._attr_unique_id = f"{unique_id}_{sensor_key}"
|
||||
sensor_data = coordinator.data.sensors[sensor_key]
|
||||
super().__init__(coordinator, sensor_data.device_identifier)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}_{sensor_key}"
|
||||
self._sensor_key = sensor_key
|
||||
|
||||
device_metadata = coordinator.data.device_metadata.get(
|
||||
sensor_data.device_identifier
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{unique_id}_{sensor_data.device_identifier}")},
|
||||
configuration_url=coordinator.client.base_url,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=device_metadata.model if device_metadata else None,
|
||||
name=device_metadata.name if device_metadata else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
|
||||
@@ -160,6 +160,22 @@
|
||||
"tmin": {
|
||||
"name": "Minimum temperature"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"local_mode": {
|
||||
"name": "Local mode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_failed": {
|
||||
"message": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"unknown_error": {
|
||||
"message": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
55
homeassistant/components/homevolt/switch.py
Normal file
55
homeassistant/components/homevolt/switch.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Support for Homevolt switch entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
|
||||
from .entity import HomevoltEntity, homevolt_exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0 # Coordinator-based updates
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomevoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Homevolt switch entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities([HomevoltLocalModeSwitch(coordinator)])
|
||||
|
||||
|
||||
class HomevoltLocalModeSwitch(HomevoltEntity, SwitchEntity):
|
||||
"""Switch entity for Homevolt local mode."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
_attr_translation_key = "local_mode"
|
||||
|
||||
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
|
||||
"""Initialize the switch entity."""
|
||||
self._attr_unique_id = f"{coordinator.data.unique_id}_local_mode"
|
||||
device_id = coordinator.data.unique_id
|
||||
super().__init__(coordinator, f"ems_{device_id}")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if local mode is enabled."""
|
||||
return self.coordinator.client.local_mode_enabled
|
||||
|
||||
@homevolt_exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Enable local mode."""
|
||||
await self.coordinator.client.enable_local_mode()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@homevolt_exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Disable local mode."""
|
||||
await self.coordinator.client.disable_local_mode()
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -6,6 +6,7 @@ from enum import Enum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from HueBLE import ConnectionError, HueBleError, HueBleLight, PairingError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -26,6 +27,17 @@ from .light import get_available_color_modes
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SERVICE_UUID = SERVICE_DATA_UUID = "0000fe0f-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def device_filter(advertisement_data: AdvertisementData) -> bool:
|
||||
"""Return True if the device is supported."""
|
||||
return (
|
||||
SERVICE_UUID in advertisement_data.service_uuids
|
||||
and SERVICE_DATA_UUID in advertisement_data.service_data
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
|
||||
"""Return error if cannot connect and validate."""
|
||||
|
||||
@@ -70,28 +82,66 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
|
||||
self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
|
||||
|
||||
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:
|
||||
unique_id = dr.format_mac(user_input[CONF_MAC])
|
||||
# Don't raise on progress because there may be discovery flows
|
||||
await self.async_set_unique_id(unique_id, raise_on_progress=False)
|
||||
# Guard against the user selecting a device which has been configured by
|
||||
# another flow.
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovery_info = self._discovered_devices[user_input[CONF_MAC]]
|
||||
return await self.async_step_confirm()
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in bluetooth.async_discovered_service_info(self.hass):
|
||||
if (
|
||||
discovery.address in current_addresses
|
||||
or discovery.address in self._discovered_devices
|
||||
or not device_filter(discovery.advertisement)
|
||||
):
|
||||
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_MAC): vol.In(
|
||||
{
|
||||
service_info.address: (
|
||||
f"{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,
|
||||
)
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the home assistant scanner."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"HA found light %s. Will show in UI but not auto connect",
|
||||
"HA found light %s. Use user flow to show in UI and connect",
|
||||
discovery_info.name,
|
||||
)
|
||||
|
||||
unique_id = dr.format_mac(discovery_info.address)
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
name = f"{discovery_info.name} ({discovery_info.address})"
|
||||
self.context.update({"title_placeholders": {CONF_NAME: name}})
|
||||
|
||||
self._discovery_info = discovery_info
|
||||
|
||||
return await self.async_step_confirm()
|
||||
return self.async_abort(reason="discovery_unsupported")
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -103,7 +153,10 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = dr.format_mac(self._discovery_info.address)
|
||||
await self.async_set_unique_id(unique_id)
|
||||
# Don't raise on progress because there may be discovery flows
|
||||
await self.async_set_unique_id(unique_id, raise_on_progress=False)
|
||||
# Guard against the user selecting a device which has been configured by
|
||||
# another flow.
|
||||
self._abort_if_unique_id_configured()
|
||||
error = await validate_input(self.hass, unique_id)
|
||||
if error:
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"not_implemented": "This integration can only be set up via discovery."
|
||||
"discovery_unsupported": "Discovery flow is not supported by the Hue BLE integration.",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -14,7 +15,16 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
|
||||
"description": "Do you want to set up {name} ({mac})?\nMake sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"mac": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"data_description": {
|
||||
"mac": "Select the Hue device you want to set up"
|
||||
},
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Provides functionality to interact with infrared devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import final
|
||||
|
||||
from infrared_protocols import Command as InfraredCommand
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"InfraredEntity",
|
||||
"InfraredEntityDescription",
|
||||
"async_get_emitters",
|
||||
"async_send_command",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the infrared domain."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
||||
)
|
||||
await component.async_setup(config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_emitters(hass: HomeAssistant) -> list[InfraredEntity]:
|
||||
"""Get all infrared emitters."""
|
||||
component = hass.data.get(DATA_COMPONENT)
|
||||
if component is None:
|
||||
return []
|
||||
|
||||
return list(component.entities)
|
||||
|
||||
|
||||
async def async_send_command(
|
||||
hass: HomeAssistant,
|
||||
entity_id_or_uuid: str,
|
||||
command: InfraredCommand,
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Send an IR command to the specified infrared entity.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If the infrared entity is not found.
|
||||
"""
|
||||
component = hass.data.get(DATA_COMPONENT)
|
||||
if component is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="component_not_loaded",
|
||||
)
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
|
||||
entity = component.get_entity(entity_id)
|
||||
if entity is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entity_not_found",
|
||||
translation_placeholders={"entity_id": entity_id},
|
||||
)
|
||||
|
||||
if context is not None:
|
||||
entity.async_set_context(context)
|
||||
|
||||
await entity.async_send_command_internal(command)
|
||||
|
||||
|
||||
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""Describes infrared entities."""
|
||||
|
||||
|
||||
class InfraredEntity(RestoreEntity):
|
||||
"""Base class for infrared transmitter entities."""
|
||||
|
||||
entity_description: InfraredEntityDescription
|
||||
_attr_should_poll = False
|
||||
_attr_state: None = None
|
||||
|
||||
__last_command_sent: str | None = None
|
||||
|
||||
@property
|
||||
@final
|
||||
def state(self) -> str | None:
|
||||
"""Return the entity state."""
|
||||
return self.__last_command_sent
|
||||
|
||||
@final
|
||||
async def async_send_command_internal(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command and update state.
|
||||
|
||||
Should not be overridden, handles setting last sent timestamp.
|
||||
"""
|
||||
await self.async_send_command(command)
|
||||
self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds")
|
||||
self.async_write_ha_state()
|
||||
|
||||
@final
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the infrared entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
|
||||
self.__last_command_sent = state.state
|
||||
|
||||
@abstractmethod
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command.
|
||||
|
||||
Args:
|
||||
command: The IR command to send.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If transmission fails.
|
||||
"""
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Constants for the Infrared integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "infrared"
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:led-on"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"domain": "infrared",
|
||||
"name": "Infrared",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==1.0.0"]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"component_not_loaded": {
|
||||
"message": "Infrared component not loaded"
|
||||
},
|
||||
"entity_not_found": {
|
||||
"message": "Infrared entity `{entity_id}` not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from .const import LOGGER
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
|
||||
PLATFORMS = [Platform.FAN]
|
||||
PLATFORMS = [Platform.FAN, Platform.SELECT]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -27,8 +27,6 @@ class IntelliClimaEntity(CoordinatorEntity[IntelliClimaCoordinator]):
|
||||
"""Class initializer."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
|
||||
self._attr_unique_id = device.id
|
||||
|
||||
# Make this HA "device" use the IntelliClima device name.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
|
||||
@@ -62,6 +62,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
self._speed_range = (int(FanSpeed.sleep), int(FanSpeed.high))
|
||||
self._attr_unique_id = device.id
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
||||
@@ -49,7 +49,7 @@ rules:
|
||||
comment: |
|
||||
Unclear if discovery is possible.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
|
||||
96
homeassistant/components/intelliclima/select.py
Normal file
96
homeassistant/components/intelliclima/select.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Select platform for IntelliClima VMC."""
|
||||
|
||||
from pyintelliclima.const import FanMode, FanSpeed
|
||||
from pyintelliclima.intelliclima_types import IntelliClimaECO
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
from .entity import IntelliClimaECOEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
FAN_MODE_TO_INTELLICLIMA_MODE = {
|
||||
"forward": FanMode.inward,
|
||||
"reverse": FanMode.outward,
|
||||
"alternate": FanMode.alternate,
|
||||
"sensor": FanMode.sensor,
|
||||
}
|
||||
INTELLICLIMA_MODE_TO_FAN_MODE = {v: k for k, v in FAN_MODE_TO_INTELLICLIMA_MODE.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: IntelliClimaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up IntelliClima VMC fan mode select."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities: list[IntelliClimaVMCFanModeSelect] = [
|
||||
IntelliClimaVMCFanModeSelect(
|
||||
coordinator=coordinator,
|
||||
device=ecocomfort2,
|
||||
)
|
||||
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
|
||||
"""Representation of an IntelliClima VMC fan mode selector."""
|
||||
|
||||
_attr_translation_key = "fan_mode"
|
||||
_attr_options = ["forward", "reverse", "alternate", "sensor"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IntelliClimaCoordinator,
|
||||
device: IntelliClimaECO,
|
||||
) -> None:
|
||||
"""Class initializer."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
self._attr_unique_id = f"{device.id}_fan_mode"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
device_data = self._device_data
|
||||
|
||||
if device_data.mode_set == FanMode.off:
|
||||
return None
|
||||
|
||||
# If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode)
|
||||
if (
|
||||
device_data.speed_set == FanSpeed.auto
|
||||
and device_data.mode_set == FanMode.sensor
|
||||
):
|
||||
return None
|
||||
|
||||
return INTELLICLIMA_MODE_TO_FAN_MODE.get(FanMode(device_data.mode_set))
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
device_data = self._device_data
|
||||
|
||||
mode = FAN_MODE_TO_INTELLICLIMA_MODE[option]
|
||||
|
||||
# Determine speed: keep current speed if available, otherwise default to sleep
|
||||
if (
|
||||
device_data.speed_set == FanSpeed.auto
|
||||
or device_data.mode_set == FanMode.off
|
||||
):
|
||||
speed = FanSpeed.sleep
|
||||
else:
|
||||
speed = device_data.speed_set
|
||||
|
||||
await self.coordinator.api.ecocomfort.set_mode_speed(
|
||||
self._device_sn, mode, speed
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -22,5 +22,18 @@
|
||||
"description": "Authenticate against IntelliClima cloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"select": {
|
||||
"fan_mode": {
|
||||
"name": "Fan direction mode",
|
||||
"state": {
|
||||
"alternate": "Alternating",
|
||||
"forward": "Forward",
|
||||
"reverse": "Reverse",
|
||||
"sensor": "Sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
Platform.BUTTON,
|
||||
Platform.FAN,
|
||||
Platform.IMAGE,
|
||||
Platform.INFRARED,
|
||||
Platform.LAWN_MOWER,
|
||||
Platform.LOCK,
|
||||
Platform.NOTIFY,
|
||||
@@ -133,9 +131,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Notify backup listeners
|
||||
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
|
||||
|
||||
# Reload config entry when subentries are added/removed/updated
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
# Subscribe to labs feature updates for kitchen_sink preview repair
|
||||
entry.async_on_unload(
|
||||
async_subscribe_preview_feature(
|
||||
@@ -152,11 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Reload config entry on update (e.g. subentry added/removed)."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload config entry."""
|
||||
# Notify backup listeners
|
||||
|
||||
@@ -8,23 +8,18 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.infrared import (
|
||||
DOMAIN as INFRARED_DOMAIN,
|
||||
async_get_emitters,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
OptionsFlow,
|
||||
OptionsFlowWithReload,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
|
||||
|
||||
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
|
||||
from . import DOMAIN
|
||||
|
||||
CONF_BOOLEAN = "bool"
|
||||
CONF_INT = "int"
|
||||
@@ -49,10 +44,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this handler."""
|
||||
return {
|
||||
"entity": SubentryFlowHandler,
|
||||
"infrared_fan": InfraredFanSubentryFlowHandler,
|
||||
}
|
||||
return {"entity": SubentryFlowHandler}
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Set the config entry up from yaml."""
|
||||
@@ -73,7 +65,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlow):
|
||||
class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
"""Handle options."""
|
||||
|
||||
async def async_step_init(
|
||||
@@ -154,7 +146,7 @@ class SubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Reconfigure a sensor."""
|
||||
if user_input is not None:
|
||||
title = user_input.pop("name")
|
||||
return self.async_update_and_abort(
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=user_input,
|
||||
@@ -170,35 +162,3 @@ class SubentryFlowHandler(ConfigSubentryFlow):
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class InfraredFanSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle infrared fan subentry flow."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""User flow to add an infrared fan."""
|
||||
|
||||
entities = async_get_emitters(self.hass)
|
||||
if not entities:
|
||||
return self.async_abort(reason="no_emitters")
|
||||
|
||||
if user_input is not None:
|
||||
title = user_input.pop("name")
|
||||
return self.async_create_entry(data=user_input, title=title)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required("name"): str,
|
||||
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
|
||||
EntitySelectorConfig(
|
||||
domain=INFRARED_DOMAIN,
|
||||
include_entities=[entity.entity_id for entity in entities],
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ from collections.abc import Callable
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN = "kitchen_sink"
|
||||
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Demo platform that offers a fake infrared fan entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import infrared_protocols
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.components.infrared import async_send_command
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
DUMMY_FAN_ADDRESS = 0x1234
|
||||
DUMMY_CMD_POWER_ON = 0x01
|
||||
DUMMY_CMD_POWER_OFF = 0x02
|
||||
DUMMY_CMD_SPEED_LOW = 0x03
|
||||
DUMMY_CMD_SPEED_MEDIUM = 0x04
|
||||
DUMMY_CMD_SPEED_HIGH = 0x05
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the demo infrared fan platform."""
|
||||
for subentry_id, subentry in config_entry.subentries.items():
|
||||
if subentry.subentry_type != "infrared_fan":
|
||||
continue
|
||||
async_add_entities(
|
||||
[
|
||||
DemoInfraredFan(
|
||||
subentry_id=subentry_id,
|
||||
device_name=subentry.title,
|
||||
infrared_entity_id=subentry.data[CONF_INFRARED_ENTITY_ID],
|
||||
)
|
||||
],
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class DemoInfraredFan(FanEntity):
|
||||
"""Representation of a demo infrared fan entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
_attr_assumed_state = True
|
||||
_attr_speed_count = 3
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.TURN_ON
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subentry_id: str,
|
||||
device_name: str,
|
||||
infrared_entity_id: str,
|
||||
) -> None:
|
||||
"""Initialize the demo infrared fan entity."""
|
||||
self._infrared_entity_id = infrared_entity_id
|
||||
self._attr_unique_id = subentry_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry_id)},
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_percentage = 0
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to infrared entity state changes."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle infrared entity state changes."""
|
||||
new_state = event.data["new_state"]
|
||||
self._attr_available = (
|
||||
new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._infrared_entity_id], _async_ir_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
# Set initial availability based on current infrared entity state
|
||||
ir_state = self.hass.states.get(self._infrared_entity_id)
|
||||
self._attr_available = (
|
||||
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
async def _send_command(self, command_code: int) -> None:
|
||||
"""Send an IR command using the NEC protocol."""
|
||||
command = infrared_protocols.NECCommand(
|
||||
address=DUMMY_FAN_ADDRESS,
|
||||
command=command_code,
|
||||
modulation=38000,
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass, self._infrared_entity_id, command, context=self._context
|
||||
)
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on the fan."""
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
return
|
||||
await self._send_command(DUMMY_CMD_POWER_ON)
|
||||
self._attr_percentage = 33
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the fan."""
|
||||
await self._send_command(DUMMY_CMD_POWER_OFF)
|
||||
self._attr_percentage = 0
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed percentage of the fan."""
|
||||
if percentage == 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
|
||||
if percentage <= 33:
|
||||
await self._send_command(DUMMY_CMD_SPEED_LOW)
|
||||
elif percentage <= 66:
|
||||
await self._send_command(DUMMY_CMD_SPEED_MEDIUM)
|
||||
else:
|
||||
await self._send_command(DUMMY_CMD_SPEED_HIGH)
|
||||
|
||||
self._attr_percentage = percentage
|
||||
self.async_write_ha_state()
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Demo platform that offers a fake infrared entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import infrared_protocols
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.components.infrared import InfraredEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the demo infrared platform."""
|
||||
async_add_entities(
|
||||
[
|
||||
DemoInfrared(
|
||||
unique_id="ir_transmitter",
|
||||
device_name="IR Blaster",
|
||||
entity_name="Infrared Transmitter",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DemoInfrared(InfraredEntity):
|
||||
"""Representation of a demo infrared entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
unique_id: str,
|
||||
device_name: str,
|
||||
entity_name: str,
|
||||
) -> None:
|
||||
"""Initialize the demo infrared entity."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_name = entity_name
|
||||
|
||||
async def async_send_command(self, command: infrared_protocols.Command) -> None:
|
||||
"""Send an IR command."""
|
||||
timings = [
|
||||
interval
|
||||
for timing in command.get_raw_timings()
|
||||
for interval in (timing.high_us, -timing.low_us)
|
||||
]
|
||||
persistent_notification.async_create(
|
||||
self.hass, str(timings), title="Infrared Command"
|
||||
)
|
||||
@@ -101,8 +101,6 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for subentry_id, subentry in config_entry.subentries.items():
|
||||
if subentry.subentry_type != "entity":
|
||||
continue
|
||||
async_add_entities(
|
||||
[
|
||||
DemoSensor(
|
||||
|
||||
@@ -32,24 +32,6 @@
|
||||
"description": "Reconfigure the sensor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"infrared_fan": {
|
||||
"abort": {
|
||||
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
|
||||
},
|
||||
"entry_type": "Infrared fan",
|
||||
"initiate_flow": {
|
||||
"user": "Add infrared fan"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"infrared_entity_id": "Infrared transmitter",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"description": "Select an infrared transmitter to control the fan."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The liebherr integration."""
|
||||
"""The Liebherr integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,7 +17,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> bool:
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"bio_fresh_plus": {
|
||||
"default": "mdi:leaf"
|
||||
},
|
||||
"bio_fresh_plus_bottom_zone": {
|
||||
"default": "mdi:leaf"
|
||||
},
|
||||
"bio_fresh_plus_middle_zone": {
|
||||
"default": "mdi:leaf"
|
||||
},
|
||||
"bio_fresh_plus_top_zone": {
|
||||
"default": "mdi:leaf"
|
||||
},
|
||||
"hydro_breeze": {
|
||||
"default": "mdi:weather-windy"
|
||||
},
|
||||
"hydro_breeze_bottom_zone": {
|
||||
"default": "mdi:weather-windy"
|
||||
},
|
||||
"hydro_breeze_middle_zone": {
|
||||
"default": "mdi:weather-windy"
|
||||
},
|
||||
"hydro_breeze_top_zone": {
|
||||
"default": "mdi:weather-windy"
|
||||
},
|
||||
"ice_maker": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-outline-off"
|
||||
}
|
||||
},
|
||||
"ice_maker_bottom_zone": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-outline-off"
|
||||
}
|
||||
},
|
||||
"ice_maker_middle_zone": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-outline-off"
|
||||
}
|
||||
},
|
||||
"ice_maker_top_zone": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-outline-off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"night_mode": {
|
||||
"default": "mdi:sleep",
|
||||
|
||||
216
homeassistant/components/liebherr/select.py
Normal file
216
homeassistant/components/liebherr/select.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Select platform for Liebherr integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pyliebherrhomeapi import (
|
||||
BioFreshPlusControl,
|
||||
BioFreshPlusMode,
|
||||
HydroBreezeControl,
|
||||
HydroBreezeMode,
|
||||
IceMakerControl,
|
||||
IceMakerMode,
|
||||
ZonePosition,
|
||||
)
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import ZONE_POSITION_MAP, LiebherrEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
type SelectControl = IceMakerControl | HydroBreezeControl | BioFreshPlusControl
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class LiebherrSelectEntityDescription(SelectEntityDescription):
|
||||
"""Describes a Liebherr select entity."""
|
||||
|
||||
control_type: type[SelectControl]
|
||||
mode_enum: type[StrEnum]
|
||||
current_mode_fn: Callable[[SelectControl], StrEnum | str | None]
|
||||
options_fn: Callable[[SelectControl], list[str]]
|
||||
set_fn: Callable[[LiebherrCoordinator, int, StrEnum], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
def _ice_maker_options(control: SelectControl) -> list[str]:
|
||||
"""Return available ice maker options."""
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(control, IceMakerControl)
|
||||
options = [IceMakerMode.OFF.value, IceMakerMode.ON.value]
|
||||
if control.has_max_ice:
|
||||
options.append(IceMakerMode.MAX_ICE.value)
|
||||
return options
|
||||
|
||||
|
||||
def _hydro_breeze_options(control: SelectControl) -> list[str]:
|
||||
"""Return available HydroBreeze options."""
|
||||
return [mode.value for mode in HydroBreezeMode]
|
||||
|
||||
|
||||
def _bio_fresh_plus_options(control: SelectControl) -> list[str]:
|
||||
"""Return available BioFresh-Plus options."""
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(control, BioFreshPlusControl)
|
||||
return [
|
||||
mode.value
|
||||
for mode in control.supported_modes
|
||||
if isinstance(mode, BioFreshPlusMode)
|
||||
]
|
||||
|
||||
|
||||
SELECT_TYPES: list[LiebherrSelectEntityDescription] = [
|
||||
LiebherrSelectEntityDescription(
|
||||
key="ice_maker",
|
||||
translation_key="ice_maker",
|
||||
control_type=IceMakerControl,
|
||||
mode_enum=IceMakerMode,
|
||||
current_mode_fn=lambda c: c.ice_maker_mode, # type: ignore[union-attr]
|
||||
options_fn=_ice_maker_options,
|
||||
set_fn=lambda coordinator, zone_id, mode: coordinator.client.set_ice_maker(
|
||||
device_id=coordinator.device_id,
|
||||
zone_id=zone_id,
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
),
|
||||
),
|
||||
LiebherrSelectEntityDescription(
|
||||
key="hydro_breeze",
|
||||
translation_key="hydro_breeze",
|
||||
control_type=HydroBreezeControl,
|
||||
mode_enum=HydroBreezeMode,
|
||||
current_mode_fn=lambda c: c.current_mode, # type: ignore[union-attr]
|
||||
options_fn=_hydro_breeze_options,
|
||||
set_fn=lambda coordinator, zone_id, mode: coordinator.client.set_hydro_breeze(
|
||||
device_id=coordinator.device_id,
|
||||
zone_id=zone_id,
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
),
|
||||
),
|
||||
LiebherrSelectEntityDescription(
|
||||
key="bio_fresh_plus",
|
||||
translation_key="bio_fresh_plus",
|
||||
control_type=BioFreshPlusControl,
|
||||
mode_enum=BioFreshPlusMode,
|
||||
current_mode_fn=lambda c: c.current_mode, # type: ignore[union-attr]
|
||||
options_fn=_bio_fresh_plus_options,
|
||||
set_fn=lambda coordinator, zone_id, mode: coordinator.client.set_bio_fresh_plus(
|
||||
device_id=coordinator.device_id,
|
||||
zone_id=zone_id,
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr select entities."""
|
||||
entities: list[LiebherrSelectEntity] = []
|
||||
|
||||
for coordinator in entry.runtime_data.values():
|
||||
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
|
||||
|
||||
for control in coordinator.data.controls:
|
||||
for description in SELECT_TYPES:
|
||||
if isinstance(control, description.control_type):
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(
|
||||
control,
|
||||
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
|
||||
)
|
||||
entities.append(
|
||||
LiebherrSelectEntity(
|
||||
coordinator=coordinator,
|
||||
description=description,
|
||||
zone_id=control.zone_id,
|
||||
has_multiple_zones=has_multiple_zones,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LiebherrSelectEntity(LiebherrEntity, SelectEntity):
|
||||
"""Representation of a Liebherr select entity."""
|
||||
|
||||
entity_description: LiebherrSelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: LiebherrCoordinator,
|
||||
description: LiebherrSelectEntityDescription,
|
||||
zone_id: int,
|
||||
has_multiple_zones: bool,
|
||||
) -> None:
|
||||
"""Initialize the select entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._zone_id = zone_id
|
||||
self._attr_unique_id = f"{coordinator.device_id}_{description.key}_{zone_id}"
|
||||
|
||||
# Set options from the control
|
||||
control = self._select_control
|
||||
if control is not None:
|
||||
self._attr_options = description.options_fn(control)
|
||||
|
||||
# Add zone suffix only for multi-zone devices
|
||||
if has_multiple_zones:
|
||||
temp_controls = coordinator.data.get_temperature_controls()
|
||||
if (
|
||||
(tc := temp_controls.get(zone_id))
|
||||
and isinstance(tc.zone_position, ZonePosition)
|
||||
and (zone_key := ZONE_POSITION_MAP.get(tc.zone_position))
|
||||
):
|
||||
self._attr_translation_key = f"{description.translation_key}_{zone_key}"
|
||||
|
||||
@property
|
||||
def _select_control(self) -> SelectControl | None:
|
||||
"""Get the select control for this entity."""
|
||||
for control in self.coordinator.data.controls:
|
||||
if (
|
||||
isinstance(control, self.entity_description.control_type)
|
||||
and control.zone_id == self._zone_id
|
||||
):
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(
|
||||
control,
|
||||
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
|
||||
)
|
||||
return control
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current selected option."""
|
||||
control = self._select_control
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(
|
||||
control,
|
||||
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
|
||||
)
|
||||
mode = self.entity_description.current_mode_fn(control)
|
||||
if isinstance(mode, StrEnum):
|
||||
return mode.value
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._select_control is not None
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
mode = self.entity_description.mode_enum(option)
|
||||
await self._async_send_command(
|
||||
self.entity_description.set_fn(self.coordinator, self._zone_id, mode),
|
||||
)
|
||||
@@ -47,6 +47,112 @@
|
||||
"name": "Top zone setpoint"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"bio_fresh_plus": {
|
||||
"name": "BioFresh-Plus",
|
||||
"state": {
|
||||
"minus_two_minus_two": "-2°C | -2°C",
|
||||
"minus_two_zero": "-2°C | 0°C",
|
||||
"zero_minus_two": "0°C | -2°C",
|
||||
"zero_zero": "0°C | 0°C"
|
||||
}
|
||||
},
|
||||
"bio_fresh_plus_bottom_zone": {
|
||||
"name": "Bottom zone BioFresh-Plus",
|
||||
"state": {
|
||||
"minus_two_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_minus_two%]",
|
||||
"minus_two_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_zero%]",
|
||||
"zero_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_minus_two%]",
|
||||
"zero_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_zero%]"
|
||||
}
|
||||
},
|
||||
"bio_fresh_plus_middle_zone": {
|
||||
"name": "Middle zone BioFresh-Plus",
|
||||
"state": {
|
||||
"minus_two_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_minus_two%]",
|
||||
"minus_two_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_zero%]",
|
||||
"zero_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_minus_two%]",
|
||||
"zero_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_zero%]"
|
||||
}
|
||||
},
|
||||
"bio_fresh_plus_top_zone": {
|
||||
"name": "Top zone BioFresh-Plus",
|
||||
"state": {
|
||||
"minus_two_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_minus_two%]",
|
||||
"minus_two_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_zero%]",
|
||||
"zero_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_minus_two%]",
|
||||
"zero_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_zero%]"
|
||||
}
|
||||
},
|
||||
"hydro_breeze": {
|
||||
"name": "HydroBreeze",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"hydro_breeze_bottom_zone": {
|
||||
"name": "Bottom zone HydroBreeze",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"hydro_breeze_middle_zone": {
|
||||
"name": "Middle zone HydroBreeze",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"hydro_breeze_top_zone": {
|
||||
"name": "Top zone HydroBreeze",
|
||||
"state": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"ice_maker": {
|
||||
"name": "IceMaker",
|
||||
"state": {
|
||||
"max_ice": "MaxIce",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"ice_maker_bottom_zone": {
|
||||
"name": "Bottom zone IceMaker",
|
||||
"state": {
|
||||
"max_ice": "[%key:component::liebherr::entity::select::ice_maker::state::max_ice%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"ice_maker_middle_zone": {
|
||||
"name": "Middle zone IceMaker",
|
||||
"state": {
|
||||
"max_ice": "[%key:component::liebherr::entity::select::ice_maker::state::max_ice%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"ice_maker_top_zone": {
|
||||
"name": "Top zone IceMaker",
|
||||
"state": {
|
||||
"max_ice": "[%key:component::liebherr::entity::select::ice_maker::state::max_ice%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"bottom_zone": {
|
||||
"name": "Bottom zone"
|
||||
|
||||
@@ -109,14 +109,18 @@ class LunatoneLight(
|
||||
return self._device is not None and self._device.is_on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
return (
|
||||
value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
if self._device.brightness is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._device is not None and self._device.is_dimmable:
|
||||
if self._device is not None and self._device.brightness is not None:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@@ -149,7 +153,8 @@ class LunatoneLight(
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn off."""
|
||||
if brightness_supported(self.supported_color_modes):
|
||||
self._last_brightness = self.brightness
|
||||
if self.brightness:
|
||||
self._last_brightness = self.brightness
|
||||
await self._device.fade_to_brightness(0)
|
||||
else:
|
||||
await self._device.switch_off()
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lunatone-rest-api-client==0.6.3"]
|
||||
"requirements": ["lunatone-rest-api-client==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2026.02.04"],
|
||||
"requirements": ["yt-dlp[default]==2026.02.21"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN as DOMAIN
|
||||
from .const import DOMAIN as DOMAIN, SUBENTRY_TYPE_BUS, SUBENTRY_TYPE_SUBWAY
|
||||
from .coordinator import MTAConfigEntry, MTADataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
@@ -13,16 +15,36 @@ PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool:
|
||||
"""Set up MTA from a config entry."""
|
||||
coordinator = MTADataUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
coordinators: dict[str, MTADataUpdateCoordinator] = {}
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
for subentry_id, subentry in entry.subentries.items():
|
||||
if subentry.subentry_type not in (SUBENTRY_TYPE_SUBWAY, SUBENTRY_TYPE_BUS):
|
||||
continue
|
||||
|
||||
coordinators[subentry_id] = MTADataUpdateCoordinator(hass, entry, subentry)
|
||||
|
||||
# Refresh all coordinators in parallel
|
||||
await asyncio.gather(
|
||||
*(
|
||||
coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in coordinators.values()
|
||||
)
|
||||
)
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_entry))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> None:
|
||||
"""Handle config entry update (e.g., subentry changes)."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -2,22 +2,43 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pymta import LINE_TO_FEED, MTAFeedError, SubwayFeed
|
||||
from pymta import LINE_TO_FEED, BusFeed, MTAFeedError, SubwayFeed
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN
|
||||
from .const import (
|
||||
CONF_LINE,
|
||||
CONF_ROUTE,
|
||||
CONF_STOP_ID,
|
||||
CONF_STOP_NAME,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_BUS,
|
||||
SUBENTRY_TYPE_SUBWAY,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,17 +49,79 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.stops: dict[str, str] = {}
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this handler."""
|
||||
return {
|
||||
SUBENTRY_TYPE_SUBWAY: SubwaySubentryFlowHandler,
|
||||
SUBENTRY_TYPE_BUS: BusSubentryFlowHandler,
|
||||
}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
||||
if api_key:
|
||||
# Test the API key by trying to fetch bus data
|
||||
session = async_get_clientsession(self.hass)
|
||||
bus_feed = BusFeed(api_key=api_key, session=session)
|
||||
try:
|
||||
# Try to get stops for a known route to validate the key
|
||||
await bus_feed.get_stops(route_id="M15")
|
||||
except MTAFeedError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error validating API key")
|
||||
errors["base"] = "unknown"
|
||||
if not errors:
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_KEY: api_key or None},
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title="MTA",
|
||||
data={CONF_API_KEY: api_key or None},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_API_KEY): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, _entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth when user wants to add or update API key."""
|
||||
return await self.async_step_user()
|
||||
|
||||
|
||||
class SubwaySubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subway stop subentry flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.stops: dict[str, str] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the line selection step."""
|
||||
if user_input is not None:
|
||||
self.data[CONF_LINE] = user_input[CONF_LINE]
|
||||
return await self.async_step_stop()
|
||||
@@ -58,13 +141,12 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_stop(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the stop step."""
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the stop selection step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
@@ -74,25 +156,30 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.data[CONF_STOP_NAME] = stop_name
|
||||
|
||||
unique_id = f"{self.data[CONF_LINE]}_{stop_id}"
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Test connection to real-time GTFS-RT feed (different from static GTFS used by get_stops)
|
||||
# Check for duplicate subentries across all entries
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Test connection to real-time GTFS-RT feed
|
||||
try:
|
||||
await self._async_test_connection()
|
||||
except MTAFeedError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
title = f"{self.data[CONF_LINE]} Line - {stop_name}"
|
||||
title = f"{self.data[CONF_LINE]} - {stop_name}"
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data=self.data,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
try:
|
||||
self.stops = await self._async_get_stops(self.data[CONF_LINE])
|
||||
except MTAFeedError:
|
||||
_LOGGER.exception("Error fetching stops for line %s", self.data[CONF_LINE])
|
||||
_LOGGER.debug("Error fetching stops for line %s", self.data[CONF_LINE])
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
if not self.stops:
|
||||
@@ -123,7 +210,7 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
async def _async_get_stops(self, line: str) -> dict[str, str]:
|
||||
"""Get stops for a line from the library."""
|
||||
feed_id = SubwayFeed.get_feed_id_for_route(line)
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
subway_feed = SubwayFeed(feed_id=feed_id, session=session)
|
||||
stops_list = await subway_feed.get_stops(route_id=line)
|
||||
@@ -141,7 +228,7 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
async def _async_test_connection(self) -> None:
|
||||
"""Test connection to MTA feed."""
|
||||
feed_id = SubwayFeed.get_feed_id_for_route(self.data[CONF_LINE])
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
subway_feed = SubwayFeed(feed_id=feed_id, session=session)
|
||||
await subway_feed.get_arrivals(
|
||||
@@ -149,3 +236,133 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
stop_id=self.data[CONF_STOP_ID],
|
||||
max_arrivals=1,
|
||||
)
|
||||
|
||||
|
||||
class BusSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle bus stop subentry flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.stops: dict[str, str] = {}
|
||||
|
||||
def _get_api_key(self) -> str:
|
||||
"""Get API key from parent entry."""
|
||||
return self._get_entry().data.get(CONF_API_KEY) or ""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the route input step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
route = user_input[CONF_ROUTE].upper().strip()
|
||||
self.data[CONF_ROUTE] = route
|
||||
|
||||
# Validate route by fetching stops
|
||||
try:
|
||||
self.stops = await self._async_get_stops(route)
|
||||
if not self.stops:
|
||||
errors["base"] = "invalid_route"
|
||||
else:
|
||||
return await self.async_step_stop()
|
||||
except MTAFeedError:
|
||||
_LOGGER.debug("Error fetching stops for route %s", route)
|
||||
errors["base"] = "invalid_route"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ROUTE): TextSelector(),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_stop(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the stop selection step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
stop_id = user_input[CONF_STOP_ID]
|
||||
self.data[CONF_STOP_ID] = stop_id
|
||||
stop_name = self.stops.get(stop_id, stop_id)
|
||||
self.data[CONF_STOP_NAME] = stop_name
|
||||
|
||||
unique_id = f"bus_{self.data[CONF_ROUTE]}_{stop_id}"
|
||||
|
||||
# Check for duplicate subentries across all entries
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Test connection to real-time feed
|
||||
try:
|
||||
await self._async_test_connection()
|
||||
except MTAFeedError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
title = f"{self.data[CONF_ROUTE]} - {stop_name}"
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data=self.data,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
stop_options = [
|
||||
SelectOptionDict(value=stop_id, label=stop_name)
|
||||
for stop_id, stop_name in sorted(self.stops.items(), key=lambda x: x[1])
|
||||
]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="stop",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STOP_ID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=stop_options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"route": self.data[CONF_ROUTE]},
|
||||
)
|
||||
|
||||
async def _async_get_stops(self, route: str) -> dict[str, str]:
|
||||
"""Get stops for a bus route from the library."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
api_key = self._get_api_key()
|
||||
|
||||
bus_feed = BusFeed(api_key=api_key, session=session)
|
||||
stops_list = await bus_feed.get_stops(route_id=route)
|
||||
|
||||
stops = {}
|
||||
for stop in stops_list:
|
||||
stop_id = stop["stop_id"]
|
||||
stop_name = stop["stop_name"]
|
||||
# Add direction if available (e.g., "to South Ferry")
|
||||
if direction := stop.get("direction_name"):
|
||||
stops[stop_id] = f"{stop_name} (to {direction})"
|
||||
else:
|
||||
stops[stop_id] = stop_name
|
||||
|
||||
return stops
|
||||
|
||||
async def _async_test_connection(self) -> None:
|
||||
"""Test connection to MTA bus feed."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
api_key = self._get_api_key()
|
||||
|
||||
bus_feed = BusFeed(api_key=api_key, session=session)
|
||||
await bus_feed.get_arrivals(
|
||||
route_id=self.data[CONF_ROUTE],
|
||||
stop_id=self.data[CONF_STOP_ID],
|
||||
max_arrivals=1,
|
||||
)
|
||||
|
||||
@@ -7,5 +7,9 @@ DOMAIN = "mta"
|
||||
CONF_LINE = "line"
|
||||
CONF_STOP_ID = "stop_id"
|
||||
CONF_STOP_NAME = "stop_name"
|
||||
CONF_ROUTE = "route"
|
||||
|
||||
SUBENTRY_TYPE_SUBWAY = "subway"
|
||||
SUBENTRY_TYPE_BUS = "bus"
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
@@ -6,22 +6,30 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from pymta import MTAFeedError, SubwayFeed
|
||||
from pymta import BusFeed, MTAFeedError, SubwayFeed
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, UPDATE_INTERVAL
|
||||
from .const import (
|
||||
CONF_LINE,
|
||||
CONF_ROUTE,
|
||||
CONF_STOP_ID,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_BUS,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MTAArrival:
|
||||
"""Represents a single train arrival."""
|
||||
"""Represents a single transit arrival."""
|
||||
|
||||
arrival_time: datetime
|
||||
minutes_until: int
|
||||
@@ -36,7 +44,7 @@ class MTAData:
|
||||
arrivals: list[MTAArrival]
|
||||
|
||||
|
||||
type MTAConfigEntry = ConfigEntry[MTADataUpdateCoordinator]
|
||||
type MTAConfigEntry = ConfigEntry[dict[str, MTADataUpdateCoordinator]]
|
||||
|
||||
|
||||
class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]):
|
||||
@@ -44,35 +52,48 @@ class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]):
|
||||
|
||||
config_entry: MTAConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: MTAConfigEntry) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MTAConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
self.line = config_entry.data[CONF_LINE]
|
||||
self.stop_id = config_entry.data[CONF_STOP_ID]
|
||||
self.subentry = subentry
|
||||
self.stop_id = subentry.data[CONF_STOP_ID]
|
||||
|
||||
self.feed_id = SubwayFeed.get_feed_id_for_route(self.line)
|
||||
session = async_get_clientsession(hass)
|
||||
self.subway_feed = SubwayFeed(feed_id=self.feed_id, session=session)
|
||||
|
||||
if subentry.subentry_type == SUBENTRY_TYPE_BUS:
|
||||
api_key = config_entry.data.get(CONF_API_KEY) or ""
|
||||
self.feed: BusFeed | SubwayFeed = BusFeed(api_key=api_key, session=session)
|
||||
self.route_id = subentry.data[CONF_ROUTE]
|
||||
else:
|
||||
# Subway feed
|
||||
line = subentry.data[CONF_LINE]
|
||||
feed_id = SubwayFeed.get_feed_id_for_route(line)
|
||||
self.feed = SubwayFeed(feed_id=feed_id, session=session)
|
||||
self.route_id = line
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
name=f"{DOMAIN}_{subentry.subentry_id}",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> MTAData:
|
||||
"""Fetch data from MTA."""
|
||||
_LOGGER.debug(
|
||||
"Fetching data for line=%s, stop=%s, feed=%s",
|
||||
self.line,
|
||||
"Fetching data for route=%s, stop=%s",
|
||||
self.route_id,
|
||||
self.stop_id,
|
||||
self.feed_id,
|
||||
)
|
||||
|
||||
try:
|
||||
library_arrivals = await self.subway_feed.get_arrivals(
|
||||
route_id=self.line,
|
||||
library_arrivals = await self.feed.get_arrivals(
|
||||
route_id=self.route_id,
|
||||
stop_id=self.stop_id,
|
||||
max_arrivals=3,
|
||||
)
|
||||
|
||||
@@ -38,9 +38,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: No authentication required.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -11,12 +11,13 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN
|
||||
from .const import CONF_LINE, CONF_ROUTE, CONF_STOP_NAME, DOMAIN, SUBENTRY_TYPE_BUS
|
||||
from .coordinator import MTAArrival, MTAConfigEntry, MTADataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -97,16 +98,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MTA sensor based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
MTASensor(coordinator, entry, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
)
|
||||
for subentry_id, coordinator in entry.runtime_data.items():
|
||||
subentry = entry.subentries[subentry_id]
|
||||
async_add_entities(
|
||||
(
|
||||
MTASensor(coordinator, subentry, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
),
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity):
|
||||
"""Sensor for MTA train arrivals."""
|
||||
"""Sensor for MTA transit arrivals."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: MTASensorEntityDescription
|
||||
@@ -114,24 +118,32 @@ class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MTADataUpdateCoordinator,
|
||||
entry: MTAConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
description: MTASensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = description
|
||||
line = entry.data[CONF_LINE]
|
||||
stop_id = entry.data[CONF_STOP_ID]
|
||||
stop_name = entry.data.get(CONF_STOP_NAME, stop_id)
|
||||
|
||||
self._attr_unique_id = f"{entry.unique_id}-{description.key}"
|
||||
is_bus = subentry.subentry_type == SUBENTRY_TYPE_BUS
|
||||
if is_bus:
|
||||
route = subentry.data[CONF_ROUTE]
|
||||
model = "Bus"
|
||||
else:
|
||||
route = subentry.data[CONF_LINE]
|
||||
model = "Subway"
|
||||
|
||||
stop_name = subentry.data.get(CONF_STOP_NAME, subentry.subentry_id)
|
||||
|
||||
unique_id = subentry.unique_id or subentry.subentry_id
|
||||
self._attr_unique_id = f"{unique_id}-{description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=f"{line} Line - {stop_name} ({stop_id})",
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=f"{route} - {stop_name}",
|
||||
manufacturer="MTA",
|
||||
model="Subway",
|
||||
model=model,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,32 +2,95 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_stops": "No stops found for this line. The line may not be currently running."
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"stop": {
|
||||
"data": {
|
||||
"stop_id": "Stop and direction"
|
||||
},
|
||||
"data_description": {
|
||||
"stop_id": "Select the stop and direction you want to track"
|
||||
},
|
||||
"description": "Choose a stop on the {line} line. The direction is included with each stop.",
|
||||
"title": "Select stop and direction"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"line": "Line"
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"line": "The subway line to track"
|
||||
"api_key": "API key from MTA Bus Time. Required for bus tracking, optional for subway only."
|
||||
},
|
||||
"description": "Choose the subway line you want to track.",
|
||||
"title": "Select subway line"
|
||||
"description": "Enter your MTA Bus Time API key to enable bus tracking. Leave blank if you only want to track subways."
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"bus": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"entry_type": "Bus stop",
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_route": "Invalid bus route. Please check the route name and try again."
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add bus stop"
|
||||
},
|
||||
"step": {
|
||||
"stop": {
|
||||
"data": {
|
||||
"stop_id": "Stop"
|
||||
},
|
||||
"data_description": {
|
||||
"stop_id": "Select the stop you want to track"
|
||||
},
|
||||
"description": "Choose a stop on the {route} route.",
|
||||
"title": "Select stop"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"route": "Route"
|
||||
},
|
||||
"data_description": {
|
||||
"route": "The bus route identifier"
|
||||
},
|
||||
"description": "Enter the bus route you want to track (for example, M15, B46, Q10).",
|
||||
"title": "Enter bus route"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subway": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_stops": "No stops found for this line. The line may not be currently running."
|
||||
},
|
||||
"entry_type": "Subway stop",
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add subway stop"
|
||||
},
|
||||
"step": {
|
||||
"stop": {
|
||||
"data": {
|
||||
"stop_id": "Stop and direction"
|
||||
},
|
||||
"data_description": {
|
||||
"stop_id": "Select the stop and direction you want to track"
|
||||
},
|
||||
"description": "Choose a stop on the {line} line. The direction is included with each stop.",
|
||||
"title": "Select stop and direction"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"line": "Line"
|
||||
},
|
||||
"data_description": {
|
||||
"line": "The subway line to track"
|
||||
},
|
||||
"description": "Choose the subway line you want to track.",
|
||||
"title": "Select subway line"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
"service": "mdi:comment-remove"
|
||||
},
|
||||
"publish": {
|
||||
"sections": {
|
||||
"actions": "mdi:gesture-tap-button"
|
||||
},
|
||||
"service": "mdi:send"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,14 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
from .coordinator import NtfyConfigEntry
|
||||
from .entity import NtfyBaseEntity
|
||||
from .services import ATTR_ATTACH_FILE, ATTR_FILENAME, ATTR_SEQUENCE_ID
|
||||
from .services import (
|
||||
ACTIONS_MAP,
|
||||
ATTR_ACTION,
|
||||
ATTR_ACTIONS,
|
||||
ATTR_ATTACH_FILE,
|
||||
ATTR_FILENAME,
|
||||
ATTR_SEQUENCE_ID,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -105,6 +112,15 @@ class NtfyNotifyEntity(NtfyBaseEntity, NotifyEntity):
|
||||
|
||||
params.setdefault(ATTR_FILENAME, media.path.name)
|
||||
|
||||
actions: list[dict[str, Any]] | None = params.get(ATTR_ACTIONS)
|
||||
if actions:
|
||||
params["actions"] = [
|
||||
ACTIONS_MAP[action[ATTR_ACTION]](
|
||||
**{k: v for k, v in action.items() if k != ATTR_ACTION}
|
||||
)
|
||||
for action in actions
|
||||
]
|
||||
|
||||
msg = Message(topic=self.topic, **params)
|
||||
try:
|
||||
await self.ntfy.publish(msg, attachment)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from aiontfy import BroadcastAction, CopyAction, HttpAction, ViewAction
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
@@ -34,6 +35,28 @@ ATTR_ATTACH_FILE = "attach_file"
|
||||
ATTR_FILENAME = "filename"
|
||||
GRP_ATTACHMENT = "attachment"
|
||||
MSG_ATTACHMENT = "Only one attachment source is allowed: URL or local file"
|
||||
ATTR_ACTIONS = "actions"
|
||||
ATTR_ACTION = "action"
|
||||
ATTR_VIEW = "view"
|
||||
ATTR_BROADCAST = "broadcast"
|
||||
ATTR_HTTP = "http"
|
||||
ATTR_LABEL = "label"
|
||||
ATTR_URL = "url"
|
||||
ATTR_CLEAR = "clear"
|
||||
ATTR_INTENT = "intent"
|
||||
ATTR_EXTRAS = "extras"
|
||||
ATTR_METHOD = "method"
|
||||
ATTR_HEADERS = "headers"
|
||||
ATTR_BODY = "body"
|
||||
ATTR_VALUE = "value"
|
||||
ATTR_COPY = "copy"
|
||||
ACTIONS_MAP = {
|
||||
ATTR_VIEW: ViewAction,
|
||||
ATTR_BROADCAST: BroadcastAction,
|
||||
ATTR_HTTP: HttpAction,
|
||||
ATTR_COPY: CopyAction,
|
||||
}
|
||||
MAX_ACTIONS_ALLOWED = 3 # ntfy only supports up to 3 actions per notification
|
||||
|
||||
|
||||
def validate_filename(params: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -45,6 +68,40 @@ def validate_filename(params: dict[str, Any]) -> dict[str, Any]:
|
||||
return params
|
||||
|
||||
|
||||
ACTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_LABEL): cv.string,
|
||||
vol.Optional(ATTR_CLEAR, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
VIEW_SCHEMA = ACTION_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_ACTION): vol.Equal("view"),
|
||||
vol.Required(ATTR_URL): vol.All(vol.Url(), vol.Coerce(URL)),
|
||||
}
|
||||
)
|
||||
BROADCAST_SCHEMA = ACTION_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_ACTION): vol.Equal("broadcast"),
|
||||
vol.Optional(ATTR_INTENT): cv.string,
|
||||
vol.Optional(ATTR_EXTRAS): dict[str, str],
|
||||
}
|
||||
)
|
||||
HTTP_SCHEMA = VIEW_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_ACTION): vol.Equal("http"),
|
||||
vol.Optional(ATTR_METHOD): cv.string,
|
||||
vol.Optional(ATTR_HEADERS): dict[str, str],
|
||||
vol.Optional(ATTR_BODY): cv.string,
|
||||
}
|
||||
)
|
||||
COPY_SCHEMA = ACTION_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_ACTION): vol.Equal("copy"),
|
||||
vol.Required(ATTR_VALUE): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_PUBLISH_SCHEMA = vol.All(
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
@@ -69,6 +126,14 @@ SERVICE_PUBLISH_SCHEMA = vol.All(
|
||||
ATTR_ATTACH_FILE, GRP_ATTACHMENT, MSG_ATTACHMENT
|
||||
): MediaSelector({"accept": ["*/*"]}),
|
||||
vol.Optional(ATTR_FILENAME): cv.string,
|
||||
vol.Optional(ATTR_ACTIONS): vol.All(
|
||||
cv.ensure_list,
|
||||
vol.Length(
|
||||
max=MAX_ACTIONS_ALLOWED,
|
||||
msg="Too many actions defined. A maximum of 3 is supported",
|
||||
),
|
||||
[vol.Any(VIEW_SCHEMA, BROADCAST_SCHEMA, HTTP_SCHEMA, COPY_SCHEMA)],
|
||||
),
|
||||
}
|
||||
),
|
||||
validate_filename,
|
||||
|
||||
@@ -99,6 +99,65 @@ publish:
|
||||
type: url
|
||||
autocomplete: url
|
||||
example: https://example.org/logo.png
|
||||
actions:
|
||||
selector:
|
||||
object:
|
||||
label_field: "label"
|
||||
description_field: "url"
|
||||
multiple: true
|
||||
translation_key: actions
|
||||
fields:
|
||||
action:
|
||||
required: true
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- value: view
|
||||
label: Open website/app
|
||||
- value: http
|
||||
label: Send HTTP request
|
||||
- value: broadcast
|
||||
label: Send Android broadcast
|
||||
- value: copy
|
||||
label: Copy to clipboard
|
||||
translation_key: action_type
|
||||
mode: dropdown
|
||||
label:
|
||||
selector:
|
||||
text:
|
||||
required: true
|
||||
clear:
|
||||
selector:
|
||||
boolean:
|
||||
url:
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
method:
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
custom_value: true
|
||||
headers:
|
||||
selector:
|
||||
object:
|
||||
body:
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
intent:
|
||||
selector:
|
||||
text:
|
||||
extras:
|
||||
selector:
|
||||
object:
|
||||
value:
|
||||
selector:
|
||||
text:
|
||||
sequence_id:
|
||||
required: false
|
||||
selector:
|
||||
|
||||
@@ -318,6 +318,50 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"actions": {
|
||||
"fields": {
|
||||
"action": {
|
||||
"description": "Select the type of action to add to the notification",
|
||||
"name": "Action type"
|
||||
},
|
||||
"body": {
|
||||
"description": "The body of the HTTP request for `http` actions.",
|
||||
"name": "HTTP body"
|
||||
},
|
||||
"clear": {
|
||||
"description": "Clear notification after action button is tapped",
|
||||
"name": "Clear notification"
|
||||
},
|
||||
"extras": {
|
||||
"description": "Extras to include in the intent as key-value pairs for 'broadcast' actions",
|
||||
"name": "Intent extras"
|
||||
},
|
||||
"headers": {
|
||||
"description": "Additional HTTP headers as key-value pairs for 'http' actions",
|
||||
"name": "HTTP headers"
|
||||
},
|
||||
"intent": {
|
||||
"description": "Android intent to send when the 'broadcast' action is triggered",
|
||||
"name": "Intent"
|
||||
},
|
||||
"label": {
|
||||
"description": "Label of the action button",
|
||||
"name": "Label"
|
||||
},
|
||||
"method": {
|
||||
"description": "HTTP method to use for the 'http' action",
|
||||
"name": "HTTP method"
|
||||
},
|
||||
"url": {
|
||||
"description": "URL to open for the 'view' action or to request for the 'http' action",
|
||||
"name": "URL"
|
||||
},
|
||||
"value": {
|
||||
"description": "Value to copy to clipboard when the 'copy' action is triggered",
|
||||
"name": "Value"
|
||||
}
|
||||
}
|
||||
},
|
||||
"priority": {
|
||||
"options": {
|
||||
"1": "Minimum",
|
||||
@@ -350,8 +394,12 @@
|
||||
"name": "Delete notification"
|
||||
},
|
||||
"publish": {
|
||||
"description": "Publishes a notification message to a ntfy topic",
|
||||
"description": "Publishes a notification message to a ntfy topic.",
|
||||
"fields": {
|
||||
"actions": {
|
||||
"description": "Up to three actions (`view`, `broadcast`, `http`, or `copy`) can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.",
|
||||
"name": "Action buttons"
|
||||
},
|
||||
"attach": {
|
||||
"description": "Attach images or other files by URL.",
|
||||
"name": "Attachment URL"
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -137,3 +138,26 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry)
|
||||
hass.config_entries.async_update_entry(entry=entry, version=4)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant,
|
||||
entry: PortainerConfigEntry,
|
||||
device: DeviceEntry,
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
coordinator = entry.runtime_data
|
||||
valid_identifiers: set[tuple[str, str]] = set()
|
||||
|
||||
# The Portainer integration creates devices for both endpoints and containers. That's why we're doing it double
|
||||
valid_identifiers.update(
|
||||
(DOMAIN, f"{entry.entry_id}_{endpoint_id}") for endpoint_id in coordinator.data
|
||||
)
|
||||
|
||||
valid_identifiers.update(
|
||||
(DOMAIN, f"{entry.entry_id}_{container_name}")
|
||||
for endpoint in coordinator.data.values()
|
||||
for container_name in endpoint.containers
|
||||
)
|
||||
|
||||
return not device.identifiers.intersection(valid_identifiers)
|
||||
|
||||
@@ -30,11 +30,8 @@ rules:
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
No reauthentication flow is defined. It will be done in a next iteration.
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
@@ -47,25 +44,27 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
No discovery is implemented, since it's software based.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
No repair issues are implemented, currently.
|
||||
stale-devices: done
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "powerfox",
|
||||
"name": "Powerfox",
|
||||
"name": "Powerfox Cloud",
|
||||
"codeowners": ["@klaasnicolaas"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/powerfox",
|
||||
|
||||
@@ -37,7 +37,10 @@ from .const import (
|
||||
)
|
||||
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR]
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
]
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
|
||||
339
homeassistant/components/proxmoxve/button.py
Normal file
339
homeassistant/components/proxmoxve/button.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""Button platform for Proxmox VE."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from proxmoxer import AuthenticationError
|
||||
from proxmoxer.core import ResourceException
|
||||
import requests
|
||||
from requests.exceptions import ConnectTimeout, SSLError
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
ButtonEntity,
|
||||
ButtonEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
|
||||
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription):
|
||||
"""Class to hold Proxmox node button description."""
|
||||
|
||||
press_action: Callable[[ProxmoxCoordinator, str], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ProxmoxVMButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Class to hold Proxmox VM button description."""
|
||||
|
||||
press_action: Callable[[ProxmoxCoordinator, str, int], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Class to hold Proxmox container button description."""
|
||||
|
||||
press_action: Callable[[ProxmoxCoordinator, str, int], None]
|
||||
|
||||
|
||||
NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
|
||||
ProxmoxNodeButtonNodeEntityDescription(
|
||||
key="reboot",
|
||||
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
|
||||
node
|
||||
).status.post(command="reboot"),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
),
|
||||
ProxmoxNodeButtonNodeEntityDescription(
|
||||
key="shutdown",
|
||||
translation_key="shutdown",
|
||||
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
|
||||
node
|
||||
).status.post(command="shutdown"),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
ProxmoxNodeButtonNodeEntityDescription(
|
||||
key="start_all",
|
||||
translation_key="start_all",
|
||||
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
|
||||
node
|
||||
).startall.post(),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
ProxmoxNodeButtonNodeEntityDescription(
|
||||
key="stop_all",
|
||||
translation_key="stop_all",
|
||||
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
|
||||
node
|
||||
).stopall.post(),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
|
||||
VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (
|
||||
ProxmoxVMButtonEntityDescription(
|
||||
key="start",
|
||||
translation_key="start",
|
||||
press_action=lambda coordinator, node, vmid: (
|
||||
coordinator.proxmox.nodes(node).qemu(vmid).status.start.post()
|
||||
),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
ProxmoxVMButtonEntityDescription(
|
||||
key="stop",
|
||||
translation_key="stop",
|
||||
press_action=lambda coordinator, node, vmid: (
|
||||
coordinator.proxmox.nodes(node).qemu(vmid).status.stop.post()
|
||||
),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
ProxmoxVMButtonEntityDescription(
|
||||
key="restart",
|
||||
press_action=lambda coordinator, node, vmid: (
|
||||
coordinator.proxmox.nodes(node).qemu(vmid).status.restart.post()
|
||||
),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
),
|
||||
ProxmoxVMButtonEntityDescription(
|
||||
key="hibernate",
|
||||
translation_key="hibernate",
|
||||
press_action=lambda coordinator, node, vmid: (
|
||||
coordinator.proxmox.nodes(node).qemu(vmid).status.hibernate.post()
|
||||
),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
ProxmoxVMButtonEntityDescription(
|
||||
key="reset",
|
||||
translation_key="reset",
|
||||
press_action=lambda coordinator, node, vmid: (
|
||||
coordinator.proxmox.nodes(node).qemu(vmid).status.reset.post()
|
||||
),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
|
||||
CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (
|
||||
ProxmoxContainerButtonEntityDescription(
|
||||
key="start",
|
||||
translation_key="start",
|
||||
press_action=lambda coordinator, node, vmid: (
|
||||
coordinator.proxmox.nodes(node).lxc(vmid).status.start.post()
|
||||
),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
ProxmoxContainerButtonEntityDescription(
|
||||
key="stop",
|
||||
translation_key="stop",
|
||||
press_action=lambda coordinator, node, vmid: (
|
||||
coordinator.proxmox.nodes(node).lxc(vmid).status.stop.post()
|
||||
),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
ProxmoxContainerButtonEntityDescription(
|
||||
key="restart",
|
||||
press_action=lambda coordinator, node, vmid: (
|
||||
coordinator.proxmox.nodes(node).lxc(vmid).status.restart.post()
|
||||
),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ProxmoxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up ProxmoxVE buttons."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None:
|
||||
"""Add new node buttons."""
|
||||
async_add_entities(
|
||||
ProxmoxNodeButtonEntity(coordinator, entity_description, node)
|
||||
for node in nodes
|
||||
for entity_description in NODE_BUTTONS
|
||||
)
|
||||
|
||||
def _async_add_new_vms(
|
||||
vms: list[tuple[ProxmoxNodeData, dict[str, Any]]],
|
||||
) -> None:
|
||||
"""Add new VM buttons."""
|
||||
async_add_entities(
|
||||
ProxmoxVMButtonEntity(coordinator, entity_description, vm, node_data)
|
||||
for (node_data, vm) in vms
|
||||
for entity_description in VM_BUTTONS
|
||||
)
|
||||
|
||||
def _async_add_new_containers(
|
||||
containers: list[tuple[ProxmoxNodeData, dict[str, Any]]],
|
||||
) -> None:
|
||||
"""Add new container buttons."""
|
||||
async_add_entities(
|
||||
ProxmoxContainerButtonEntity(
|
||||
coordinator, entity_description, container, node_data
|
||||
)
|
||||
for (node_data, container) in containers
|
||||
for entity_description in CONTAINER_BUTTONS
|
||||
)
|
||||
|
||||
coordinator.new_nodes_callbacks.append(_async_add_new_nodes)
|
||||
coordinator.new_vms_callbacks.append(_async_add_new_vms)
|
||||
coordinator.new_containers_callbacks.append(_async_add_new_containers)
|
||||
|
||||
_async_add_new_nodes(
|
||||
[
|
||||
node_data
|
||||
for node_data in coordinator.data.values()
|
||||
if node_data.node["node"] in coordinator.known_nodes
|
||||
]
|
||||
)
|
||||
_async_add_new_vms(
|
||||
[
|
||||
(node_data, vm_data)
|
||||
for node_data in coordinator.data.values()
|
||||
for vmid, vm_data in node_data.vms.items()
|
||||
if (node_data.node["node"], vmid) in coordinator.known_vms
|
||||
]
|
||||
)
|
||||
_async_add_new_containers(
|
||||
[
|
||||
(node_data, container_data)
|
||||
for node_data in coordinator.data.values()
|
||||
for vmid, container_data in node_data.containers.items()
|
||||
if (node_data.node["node"], vmid) in coordinator.known_containers
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ProxmoxBaseButton(ButtonEntity):
|
||||
"""Common base for Proxmox buttons. Basically to ensure the async_press logic isn't duplicated."""
|
||||
|
||||
entity_description: ButtonEntityDescription
|
||||
coordinator: ProxmoxCoordinator
|
||||
|
||||
@abstractmethod
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Abstract method used per Proxmox button class."""
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Trigger the Proxmox button press service."""
|
||||
try:
|
||||
await self._async_press_call()
|
||||
except AuthenticationError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_no_details",
|
||||
) from err
|
||||
except SSLError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth_no_details",
|
||||
) from err
|
||||
except ConnectTimeout as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect_no_details",
|
||||
) from err
|
||||
except (ResourceException, requests.exceptions.ConnectionError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error_no_details",
|
||||
) from err
|
||||
|
||||
|
||||
class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton):
|
||||
"""Represents a Proxmox Node button entity."""
|
||||
|
||||
entity_description: ProxmoxNodeButtonNodeEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxNodeButtonNodeEntityDescription,
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox Node button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}"
|
||||
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Execute the node button action via executor."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.entity_description.press_action,
|
||||
self.coordinator,
|
||||
self._node_data.node["node"],
|
||||
)
|
||||
|
||||
|
||||
class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton):
|
||||
"""Represents a Proxmox VM button entity."""
|
||||
|
||||
entity_description: ProxmoxVMButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxVMButtonEntityDescription,
|
||||
vm_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox VM button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, vm_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Execute the VM button action via executor."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.entity_description.press_action,
|
||||
self.coordinator,
|
||||
self._node_name,
|
||||
self.vm_data["vmid"],
|
||||
)
|
||||
|
||||
|
||||
class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton):
|
||||
"""Represents a Proxmox Container button entity."""
|
||||
|
||||
entity_description: ProxmoxContainerButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProxmoxCoordinator,
|
||||
entity_description: ProxmoxContainerButtonEntityDescription,
|
||||
container_data: dict[str, Any],
|
||||
node_data: ProxmoxNodeData,
|
||||
) -> None:
|
||||
"""Initialize the Proxmox Container button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(coordinator, container_data, node_data)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
|
||||
|
||||
async def _async_press_call(self) -> None:
|
||||
"""Execute the container button action via executor."""
|
||||
await self.hass.async_add_executor_job(
|
||||
self.entity_description.press_action,
|
||||
self.coordinator,
|
||||
self._node_name,
|
||||
self.container_data["vmid"],
|
||||
)
|
||||
18
homeassistant/components/proxmoxve/icons.json
Normal file
18
homeassistant/components/proxmoxve/icons.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"hibernate": {
|
||||
"default": "mdi:power-sleep"
|
||||
},
|
||||
"reset": {
|
||||
"default": "mdi:restart"
|
||||
},
|
||||
"start": {
|
||||
"default": "mdi:play"
|
||||
},
|
||||
"stop": {
|
||||
"default": "mdi:stop"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,15 +54,47 @@
|
||||
"status": {
|
||||
"name": "Status"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"hibernate": {
|
||||
"name": "Hibernate"
|
||||
},
|
||||
"reset": {
|
||||
"name": "Reset"
|
||||
},
|
||||
"shutdown": {
|
||||
"name": "Shutdown"
|
||||
},
|
||||
"start": {
|
||||
"name": "Start"
|
||||
},
|
||||
"start_all": {
|
||||
"name": "Start all"
|
||||
},
|
||||
"stop": {
|
||||
"name": "Stop"
|
||||
},
|
||||
"stop_all": {
|
||||
"name": "Stop all"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error_no_details": {
|
||||
"message": "An error occurred while communicating with the Proxmox VE instance."
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "An error occurred while trying to connect to the Proxmox VE instance: {error}"
|
||||
},
|
||||
"cannot_connect_no_details": {
|
||||
"message": "Could not connect to the Proxmox VE instance."
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "An error occurred while trying to authenticate: {error}"
|
||||
},
|
||||
"invalid_auth_no_details": {
|
||||
"message": "Authentication failed for the Proxmox VE instance."
|
||||
},
|
||||
"no_nodes_found": {
|
||||
"message": "No active nodes were found on the Proxmox VE server."
|
||||
},
|
||||
@@ -71,6 +103,9 @@
|
||||
},
|
||||
"timeout_connect": {
|
||||
"message": "A timeout occurred while trying to connect to the Proxmox VE instance: {error}"
|
||||
},
|
||||
"timeout_connect_no_details": {
|
||||
"message": "A timeout occurred while trying to connect to the Proxmox VE instance."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -9,6 +9,7 @@ from satel_integra.satel_integra import AlarmState
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .client import SatelClient
|
||||
@@ -16,6 +17,8 @@ from .const import ZONES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARTITION_UPDATE_DEBOUNCE_DELAY = 0.15
|
||||
|
||||
|
||||
@dataclass
|
||||
class SatelIntegraData:
|
||||
@@ -106,9 +109,21 @@ class SatelIntegraPartitionsCoordinator(
|
||||
|
||||
self.data = {}
|
||||
|
||||
self._debouncer = Debouncer(
|
||||
hass=self.hass,
|
||||
logger=_LOGGER,
|
||||
cooldown=PARTITION_UPDATE_DEBOUNCE_DELAY,
|
||||
immediate=False,
|
||||
function=callback(
|
||||
lambda: self.async_set_updated_data(
|
||||
self.client.controller.partition_states
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@callback
|
||||
def partitions_update_callback(self) -> None:
|
||||
"""Update partition objects as per notification from the alarm."""
|
||||
_LOGGER.debug("Sending request to update panel state")
|
||||
|
||||
self.async_set_updated_data(self.client.controller.partition_states)
|
||||
self._debouncer.async_schedule_call()
|
||||
|
||||
@@ -160,7 +160,10 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
|
||||
if self._device.connected:
|
||||
if self.is_volume_muted or self._current_group.muted:
|
||||
return MediaPlayerState.IDLE
|
||||
return STREAM_STATUS.get(self._current_group.stream_status)
|
||||
try:
|
||||
return STREAM_STATUS.get(self._current_group.stream_status)
|
||||
except KeyError:
|
||||
pass
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
@property
|
||||
@@ -275,10 +278,15 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def metadata(self) -> Mapping[str, Any]:
|
||||
"""Get metadata from the current stream."""
|
||||
if metadata := self.coordinator.server.stream(
|
||||
self._current_group.stream
|
||||
).metadata:
|
||||
return metadata
|
||||
try:
|
||||
if metadata := self.coordinator.server.stream(
|
||||
self._current_group.stream
|
||||
).metadata:
|
||||
return metadata
|
||||
except (
|
||||
KeyError
|
||||
): # the stream function raises KeyError if the stream does not exist
|
||||
pass
|
||||
|
||||
# Fallback to an empty dict
|
||||
return {}
|
||||
@@ -333,11 +341,15 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
|
||||
@property
|
||||
def media_position(self) -> int | None:
|
||||
"""Position of current playing media in seconds."""
|
||||
# Position is part of properties object, not metadata object
|
||||
if properties := self.coordinator.server.stream(
|
||||
self._current_group.stream
|
||||
).properties:
|
||||
if (value := properties.get("position")) is not None:
|
||||
return int(value)
|
||||
|
||||
try:
|
||||
# Position is part of properties object, not metadata object
|
||||
if properties := self.coordinator.server.stream(
|
||||
self._current_group.stream
|
||||
).properties:
|
||||
if (value := properties.get("position")) is not None:
|
||||
return int(value)
|
||||
except (
|
||||
KeyError
|
||||
): # the stream function raises KeyError if the stream does not exist
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["hass_splunk"],
|
||||
"quality_scale": "legacy",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["hass-splunk==0.1.4"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -18,18 +18,9 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not provide custom actions.
|
||||
docs-high-level-description:
|
||||
status: todo
|
||||
comment: |
|
||||
Verify integration docs at https://www.home-assistant.io/integrations/splunk/ include a high-level description of Splunk with a link to https://www.splunk.com/ and explain the integration's purpose for users unfamiliar with Splunk.
|
||||
docs-installation-instructions:
|
||||
status: todo
|
||||
comment: |
|
||||
Verify integration docs include clear prerequisites and step-by-step setup instructions including how to configure Splunk HTTP Event Collector and obtain the required token.
|
||||
docs-removal-instructions:
|
||||
status: todo
|
||||
comment: |
|
||||
Verify integration docs include instructions on how to remove the integration and clarify what happens to data already in Splunk.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from abc import abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -430,48 +430,35 @@ class TelegramNotificationService:
|
||||
params[ATTR_PARSER] = None
|
||||
return params
|
||||
|
||||
async def _send_msgs(
|
||||
async def _send_msg_formatted(
|
||||
self,
|
||||
func_send: Callable,
|
||||
func_send: Callable[..., Awaitable[Message]],
|
||||
message_tag: str | None,
|
||||
*args_msg: Any,
|
||||
context: Context | None = None,
|
||||
**kwargs_msg: Any,
|
||||
) -> dict[str, JsonValueType]:
|
||||
"""Sends a message to each of the targets.
|
||||
|
||||
If there is only 1 targtet, an error is raised if the send fails.
|
||||
For multiple targets, errors are logged and the caller is responsible for checking which target is successful/failed based on the return value.
|
||||
"""Sends a message and formats the response.
|
||||
|
||||
:return: dict with chat_id keys and message_id values for successful sends
|
||||
"""
|
||||
chat_ids = [kwargs_msg.pop(ATTR_CHAT_ID)]
|
||||
msg_ids: dict[str, JsonValueType] = {}
|
||||
for chat_id in chat_ids:
|
||||
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
|
||||
chat_id: int = kwargs_msg.pop(ATTR_CHAT_ID)
|
||||
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
|
||||
|
||||
for file_type in _FILE_TYPES:
|
||||
if file_type in kwargs_msg and isinstance(
|
||||
kwargs_msg[file_type], io.BytesIO
|
||||
):
|
||||
kwargs_msg[file_type].seek(0)
|
||||
response: Message = await self._send_msg(
|
||||
func_send,
|
||||
message_tag,
|
||||
chat_id,
|
||||
*args_msg,
|
||||
context=context,
|
||||
**kwargs_msg,
|
||||
)
|
||||
|
||||
response: Message = await self._send_msg(
|
||||
func_send,
|
||||
message_tag,
|
||||
chat_id,
|
||||
*args_msg,
|
||||
context=context,
|
||||
**kwargs_msg,
|
||||
)
|
||||
if response:
|
||||
msg_ids[str(chat_id)] = response.id
|
||||
|
||||
return msg_ids
|
||||
return {str(chat_id): response.id}
|
||||
|
||||
async def _send_msg(
|
||||
self,
|
||||
func_send: Callable,
|
||||
func_send: Callable[..., Awaitable[Any]],
|
||||
message_tag: str | None,
|
||||
*args_msg: Any,
|
||||
context: Context | None = None,
|
||||
@@ -518,7 +505,7 @@ class TelegramNotificationService:
|
||||
title = kwargs.get(ATTR_TITLE)
|
||||
text = f"{title}\n{message}" if title else message
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_message,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
text,
|
||||
@@ -759,7 +746,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_PHOTO:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_photo,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -775,7 +762,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_STICKER:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_sticker,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -789,7 +776,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_VIDEO:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_video,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -805,7 +792,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_DOCUMENT:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_document,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -821,7 +808,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
if file_type == SERVICE_SEND_VOICE:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_voice,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -836,7 +823,7 @@ class TelegramNotificationService:
|
||||
)
|
||||
|
||||
# SERVICE_SEND_ANIMATION
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_animation,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -861,7 +848,7 @@ class TelegramNotificationService:
|
||||
stickerid = kwargs.get(ATTR_STICKER_ID)
|
||||
|
||||
if stickerid:
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_sticker,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -886,7 +873,7 @@ class TelegramNotificationService:
|
||||
latitude = float(latitude)
|
||||
longitude = float(longitude)
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_location,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
@@ -911,7 +898,7 @@ class TelegramNotificationService:
|
||||
"""Send a poll."""
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
|
||||
return await self._send_msgs(
|
||||
return await self._send_msg_formatted(
|
||||
self.bot.send_poll,
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
chat_id=kwargs[ATTR_CHAT_ID],
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"requirements": ["pyTibber==0.35.0"]
|
||||
"requirements": ["pyTibber==0.36.0"]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ OAUTH2_AUTHORIZE = (
|
||||
OAUTH2_TOKEN = (
|
||||
"https://auth.weheat.nl/auth/realms/Weheat/protocol/openid-connect/token/"
|
||||
)
|
||||
API_URL = "https://api.weheat.nl"
|
||||
API_URL = "https://api.weheat.nl/third_party"
|
||||
OAUTH2_SCOPES = ["openid", "offline_access"]
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,33 @@
|
||||
"electricity_used": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"electricity_used_cooling": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"electricity_used_defrost": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"electricity_used_dhw": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"electricity_used_heating": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"energy_output": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"energy_output_cooling": {
|
||||
"default": "mdi:snowflake"
|
||||
},
|
||||
"energy_output_defrost": {
|
||||
"default": "mdi:snowflake"
|
||||
},
|
||||
"energy_output_dhw": {
|
||||
"default": "mdi:heat-wave"
|
||||
},
|
||||
"energy_output_heating": {
|
||||
"default": "mdi:heat-wave"
|
||||
},
|
||||
"heat_pump_state": {
|
||||
"default": "mdi:state-machine"
|
||||
},
|
||||
|
||||
@@ -221,6 +221,73 @@ ENERGY_SENSORS = [
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda status: status.energy_output,
|
||||
),
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="electricity_used_heating",
|
||||
key="electricity_used_heating",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda status: status.energy_in_heating,
|
||||
),
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="electricity_used_cooling",
|
||||
key="electricity_used_cooling",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda status: status.energy_in_cooling,
|
||||
),
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="electricity_used_defrost",
|
||||
key="electricity_used_defrost",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda status: status.energy_in_defrost,
|
||||
),
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="energy_output_heating",
|
||||
key="energy_output_heating",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda status: status.energy_out_heating,
|
||||
),
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="energy_output_cooling",
|
||||
key="energy_output_cooling",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value_fn=lambda status: status.energy_out_cooling,
|
||||
),
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="energy_output_defrost",
|
||||
key="energy_output_defrost",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value_fn=lambda status: status.energy_out_defrost,
|
||||
),
|
||||
]
|
||||
|
||||
DHW_ENERGY_SENSORS = [
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="electricity_used_dhw",
|
||||
key="electricity_used_dhw",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda status: status.energy_in_dhw,
|
||||
),
|
||||
WeHeatSensorEntityDescription(
|
||||
translation_key="energy_output_dhw",
|
||||
key="energy_output_dhw",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda status: status.energy_out_dhw,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -253,6 +320,16 @@ async def async_setup_entry(
|
||||
if entity_description.value_fn(weheatdata.data_coordinator.data)
|
||||
is not None
|
||||
)
|
||||
entities.extend(
|
||||
WeheatHeatPumpSensor(
|
||||
weheatdata.heat_pump_info,
|
||||
weheatdata.energy_coordinator,
|
||||
entity_description,
|
||||
)
|
||||
for entity_description in DHW_ENERGY_SENSORS
|
||||
if entity_description.value_fn(weheatdata.energy_coordinator.data)
|
||||
is not None
|
||||
)
|
||||
entities.extend(
|
||||
WeheatHeatPumpSensor(
|
||||
weheatdata.heat_pump_info,
|
||||
|
||||
@@ -84,9 +84,33 @@
|
||||
"electricity_used": {
|
||||
"name": "Electricity used"
|
||||
},
|
||||
"electricity_used_cooling": {
|
||||
"name": "Electricity used cooling"
|
||||
},
|
||||
"electricity_used_defrost": {
|
||||
"name": "Electricity used defrost"
|
||||
},
|
||||
"electricity_used_dhw": {
|
||||
"name": "Electricity used DHW"
|
||||
},
|
||||
"electricity_used_heating": {
|
||||
"name": "Electricity used heating"
|
||||
},
|
||||
"energy_output": {
|
||||
"name": "Total energy output"
|
||||
},
|
||||
"energy_output_cooling": {
|
||||
"name": "Energy output cooling"
|
||||
},
|
||||
"energy_output_defrost": {
|
||||
"name": "Energy output defrost"
|
||||
},
|
||||
"energy_output_dhw": {
|
||||
"name": "Energy output DHW"
|
||||
},
|
||||
"energy_output_heating": {
|
||||
"name": "Energy output heating"
|
||||
},
|
||||
"heat_pump_state": {
|
||||
"state": {
|
||||
"cooling": "Cooling",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/xbox",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
|
||||
"requirements": ["python-xbox==0.1.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
74
homeassistant/components/xbox/quality_scale.yaml
Normal file
74
homeassistant/components/xbox/quality_scale.yaml
Normal file
@@ -0,0 +1,74 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: has only entity actions
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: has only entity actions
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: The integration has no configuration options
|
||||
docs-installation-parameters:
|
||||
status: exempt
|
||||
comment: The integration has no installation parameters
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Discovery is only used to start/suggest the OAuth flow; there is no connection info to update
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: nothing to reconfigure
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: has no repairs
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
4
homeassistant/generated/dhcp.py
generated
4
homeassistant/generated/dhcp.py
generated
@@ -17,6 +17,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
|
||||
"domain": "airobot",
|
||||
"hostname": "airobot-thermostat-*",
|
||||
},
|
||||
{
|
||||
"domain": "airos",
|
||||
"registered_devices": True,
|
||||
},
|
||||
{
|
||||
"domain": "airthings",
|
||||
"hostname": "airthings-view",
|
||||
|
||||
1
homeassistant/generated/entity_platforms.py
generated
1
homeassistant/generated/entity_platforms.py
generated
@@ -29,7 +29,6 @@ class EntityPlatforms(StrEnum):
|
||||
HUMIDIFIER = "humidifier"
|
||||
IMAGE = "image"
|
||||
IMAGE_PROCESSING = "image_processing"
|
||||
INFRARED = "infrared"
|
||||
LAWN_MOWER = "lawn_mower"
|
||||
LIGHT = "light"
|
||||
LOCK = "lock"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user