mirror of
https://github.com/home-assistant/core.git
synced 2026-04-01 05:16:12 +02:00
Compare commits
62 Commits
python-3.1
...
edenhaus-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bd226ddae | ||
|
|
d37e8e85e5 | ||
|
|
7ce32f0668 | ||
|
|
dc5547d7b6 | ||
|
|
de98bc7dcf | ||
|
|
a71d48085a | ||
|
|
9e20a13936 | ||
|
|
e164e65217 | ||
|
|
07998de35e | ||
|
|
5253dc11dc | ||
|
|
3f9022cd53 | ||
|
|
073f498c75 | ||
|
|
c5b24e9470 | ||
|
|
c12b7bfd18 | ||
|
|
1c2f583587 | ||
|
|
58a376e68b | ||
|
|
78b251e7cb | ||
|
|
a2c65b9126 | ||
|
|
5e443681c3 | ||
|
|
13756863f1 | ||
|
|
fd54e45aeb | ||
|
|
52af74c3b6 | ||
|
|
dc111a475e | ||
|
|
14cb42349a | ||
|
|
c42b50418e | ||
|
|
501b4e6efb | ||
|
|
ca2099b165 | ||
|
|
69b55c295d | ||
|
|
13709b1c90 | ||
|
|
2c013777db | ||
|
|
91099ea489 | ||
|
|
70cea66e5b | ||
|
|
e78bb97e84 | ||
|
|
732b170190 | ||
|
|
0a05993a4e | ||
|
|
42c3610685 | ||
|
|
4ad73da7ec | ||
|
|
0d14bdab24 | ||
|
|
157362f225 | ||
|
|
1aa380fdfa | ||
|
|
9348948afa | ||
|
|
14b9915914 | ||
|
|
607462028b | ||
|
|
8c07348a3d | ||
|
|
cda52af178 | ||
|
|
d1ccda18f7 | ||
|
|
9fb0b69f0a | ||
|
|
f0848edea9 | ||
|
|
5be12a213d | ||
|
|
20b284d0e9 | ||
|
|
49c3376c95 | ||
|
|
174b5f5593 | ||
|
|
b38e41a34a | ||
|
|
b6350478a5 | ||
|
|
b75af6d84a | ||
|
|
194485d863 | ||
|
|
d6458bc574 | ||
|
|
434f1dca2c | ||
|
|
c6ad6da6ae | ||
|
|
be3d65538d | ||
|
|
297e9e265a | ||
|
|
119dfbddea |
@@ -5,14 +5,6 @@ description: Review a GitHub pull request and provide feedback comments. Use whe
|
||||
|
||||
# Review GitHub Pull Request
|
||||
|
||||
## Preparation:
|
||||
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
|
||||
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
|
||||
- Do NOT attempt any workarounds.
|
||||
- Do NOT proceed with the review.
|
||||
- ALERT about the failure and WAIT for instructions.
|
||||
- This is a hard requirement - no exceptions.
|
||||
|
||||
## Follow these steps:
|
||||
1. Use 'gh pr view' to get the PR details and description.
|
||||
2. Use 'gh pr diff' to see all the changes in the PR.
|
||||
|
||||
650
.github/workflows/builder.yml
vendored
650
.github/workflows/builder.yml
vendored
@@ -14,7 +14,7 @@ env:
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2026.02.0"
|
||||
BASE_IMAGE_VERSION: "2026.01.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
@@ -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: |
|
||||
@@ -211,353 +211,353 @@ jobs:
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
|
||||
build_machine:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
id-token: write # For cosign signing
|
||||
strategy:
|
||||
matrix:
|
||||
machine:
|
||||
- generic-x86-64
|
||||
- khadas-vim3
|
||||
- odroid-c2
|
||||
- odroid-c4
|
||||
- odroid-m1
|
||||
- odroid-n2
|
||||
- qemuarm-64
|
||||
- qemux86-64
|
||||
- raspberrypi3-64
|
||||
- raspberrypi4-64
|
||||
- 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
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# build_machine:
|
||||
# name: Build ${{ matrix.machine }} machine core image
|
||||
# if: github.repository_owner == 'home-assistant'
|
||||
# needs: ["init", "build_base"]
|
||||
# runs-on: ${{ matrix.runs-on }}
|
||||
# permissions:
|
||||
# contents: read # To check out the repository
|
||||
# packages: write # To push to GHCR
|
||||
# id-token: write # For cosign signing
|
||||
# strategy:
|
||||
# matrix:
|
||||
# machine:
|
||||
# - generic-x86-64
|
||||
# - khadas-vim3
|
||||
# - odroid-c2
|
||||
# - odroid-c4
|
||||
# - odroid-m1
|
||||
# - odroid-n2
|
||||
# - qemuarm-64
|
||||
# - qemux86-64
|
||||
# - raspberrypi3-64
|
||||
# - raspberrypi4-64
|
||||
# - 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
|
||||
# steps:
|
||||
# - name: Checkout the repository
|
||||
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# with:
|
||||
# persist-credentials: false
|
||||
|
||||
- name: Compute extra tags
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
if [[ "${VERSION}" =~ d ]]; then
|
||||
echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${VERSION}" =~ b ]]; then
|
||||
echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# - name: Compute extra tags
|
||||
# id: tags
|
||||
# shell: bash
|
||||
# env:
|
||||
# VERSION: ${{ needs.init.outputs.version }}
|
||||
# run: |
|
||||
# if [[ "${VERSION}" =~ d ]]; then
|
||||
# echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
|
||||
# elif [[ "${VERSION}" =~ b ]]; then
|
||||
# echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
|
||||
# else
|
||||
# echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
|
||||
# fi
|
||||
|
||||
- name: Build machine image
|
||||
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
with:
|
||||
arch: ${{ matrix.arch }}
|
||||
build-args: |
|
||||
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
cache-gha: false
|
||||
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: machine/
|
||||
cosign-base-identity: "https://github.com/home-assistant/core/.*"
|
||||
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
file: machine/${{ matrix.machine }}
|
||||
image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
|
||||
image-tags: |
|
||||
${{ needs.init.outputs.version }}
|
||||
${{ steps.tags.outputs.extra_tags }}
|
||||
push: true
|
||||
version: ${{ needs.init.outputs.version }}
|
||||
# - name: Build machine image
|
||||
# uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
|
||||
# with:
|
||||
# arch: ${{ matrix.arch }}
|
||||
# build-args: |
|
||||
# BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
# cache-gha: false
|
||||
# container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# context: machine/
|
||||
# cosign-base-identity: "https://github.com/home-assistant/core/.*"
|
||||
# cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
# file: machine/${{ matrix.machine }}
|
||||
# image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
|
||||
# image-tags: |
|
||||
# ${{ needs.init.outputs.version }}
|
||||
# ${{ steps.tags.outputs.extra_tags }}
|
||||
# push: true
|
||||
# version: ${{ needs.init.outputs.version }}
|
||||
|
||||
publish_ha:
|
||||
name: Publish version files
|
||||
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
|
||||
# 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: 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
|
||||
# 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"]'
|
||||
# - 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@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
# 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@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0
|
||||
# with:
|
||||
# cosign-release: "v2.5.3"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# - name: Login to DockerHub
|
||||
# if: matrix.registry == 'docker.io/homeassistant'
|
||||
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
# with:
|
||||
# username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
# password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 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"
|
||||
# - 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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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') }}
|
||||
# # 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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # 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: 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
|
||||
# - 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
|
||||
# # 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[@]}"
|
||||
# # 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
|
||||
# # 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"
|
||||
# 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
|
||||
# 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
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
# - name: Set up Python
|
||||
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
# with:
|
||||
# python-version-file: ".python-version"
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: translations
|
||||
# - name: Download translations
|
||||
# uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# with:
|
||||
# name: translations
|
||||
|
||||
- name: Extract translations
|
||||
run: |
|
||||
tar xvf translations.tar.gz
|
||||
rm translations.tar.gz
|
||||
# - 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: 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
|
||||
# - 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
|
||||
# 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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# - name: Login to GitHub Container Registry
|
||||
# uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
load: true
|
||||
tags: ${{ env.HASSFEST_IMAGE_TAG }}
|
||||
# - name: Build Docker image
|
||||
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
# 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: 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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
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: Push Docker image
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# id: push
|
||||
# uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
# 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@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
with:
|
||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
# - name: Generate artifact attestation
|
||||
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
# uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
|
||||
# with:
|
||||
# subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
||||
# subject-digest: ${{ steps.push.outputs.digest }}
|
||||
# push-to-registry: true
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -280,7 +280,7 @@ jobs:
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
|
||||
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
|
||||
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
|
||||
with:
|
||||
extra-args: --all-files zizmor
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.14.3
|
||||
3.14.2
|
||||
|
||||
@@ -579,6 +579,7 @@ homeassistant.components.trmnl.*
|
||||
homeassistant.components.tts.*
|
||||
homeassistant.components.twentemilieu.*
|
||||
homeassistant.components.unifi.*
|
||||
homeassistant.components.unifi_access.*
|
||||
homeassistant.components.unifiprotect.*
|
||||
homeassistant.components.upcloud.*
|
||||
homeassistant.components.update.*
|
||||
|
||||
8
CODEOWNERS
generated
8
CODEOWNERS
generated
@@ -741,8 +741,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/honeywell/ @rdfurman @mkmer
|
||||
/homeassistant/components/hr_energy_qube/ @MattieGit
|
||||
/tests/components/hr_energy_qube/ @MattieGit
|
||||
/homeassistant/components/html5/ @alexyao2015
|
||||
/tests/components/html5/ @alexyao2015
|
||||
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
|
||||
/tests/components/html5/ @alexyao2015 @tr4nt0r
|
||||
/homeassistant/components/http/ @home-assistant/core
|
||||
/tests/components/http/ @home-assistant/core
|
||||
/homeassistant/components/huawei_lte/ @scop @fphammerle
|
||||
@@ -1232,8 +1232,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/onvif/ @jterrace
|
||||
/homeassistant/components/open_meteo/ @frenck
|
||||
/tests/components/open_meteo/ @frenck
|
||||
/homeassistant/components/open_router/ @joostlek
|
||||
/tests/components/open_router/ @joostlek
|
||||
/homeassistant/components/open_router/ @joostlek @ab3lson
|
||||
/tests/components/open_router/ @joostlek @ab3lson
|
||||
/homeassistant/components/opendisplay/ @g4bri3lDev
|
||||
/tests/components/opendisplay/ @g4bri3lDev
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from python_homeassistant_analytics import (
|
||||
Environment,
|
||||
HomeassistantAnalyticsClient,
|
||||
HomeassistantAnalyticsConnectionError,
|
||||
)
|
||||
@@ -38,7 +39,7 @@ async def async_setup_entry(
|
||||
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass))
|
||||
|
||||
try:
|
||||
integrations = await client.get_integrations()
|
||||
integrations = await client.get_integrations(Environment.NEXT)
|
||||
except HomeassistantAnalyticsConnectionError as ex:
|
||||
raise ConfigEntryNotReady("Could not fetch integration list") from ex
|
||||
|
||||
|
||||
@@ -71,6 +71,16 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-0",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
@@ -19,6 +19,8 @@ from anthropic.types import (
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
CodeExecutionTool20250825Param,
|
||||
CodeExecutionToolResultBlock,
|
||||
CodeExecutionToolResultBlockParamContentParam,
|
||||
Container,
|
||||
ContentBlockParam,
|
||||
DocumentBlockParam,
|
||||
@@ -61,15 +63,16 @@ from anthropic.types import (
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
WebSearchTool20250305Param,
|
||||
WebSearchTool20260209Param,
|
||||
WebSearchToolResultBlock,
|
||||
WebSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.bash_code_execution_tool_result_block_param import (
|
||||
Content as BashCodeExecutionToolResultContentParam,
|
||||
Content as BashCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
|
||||
Content as TextEditorCodeExecutionToolResultContentParam,
|
||||
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
@@ -105,6 +108,7 @@ from .const import (
|
||||
MIN_THINKING_BUDGET,
|
||||
NON_ADAPTIVE_THINKING_MODELS,
|
||||
NON_THINKING_MODELS,
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
|
||||
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
|
||||
)
|
||||
|
||||
@@ -224,12 +228,22 @@ def _convert_content(
|
||||
},
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "code_execution":
|
||||
tool_result_block = {
|
||||
"type": "code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
CodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "bash_code_execution":
|
||||
tool_result_block = {
|
||||
"type": "bash_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
BashCodeExecutionToolResultContentParam, content.tool_result
|
||||
BashCodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "text_editor_code_execution":
|
||||
@@ -237,7 +251,7 @@ def _convert_content(
|
||||
"type": "text_editor_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
TextEditorCodeExecutionToolResultContentParam,
|
||||
TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
@@ -368,6 +382,7 @@ def _convert_content(
|
||||
name=cast(
|
||||
Literal[
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
],
|
||||
@@ -379,6 +394,7 @@ def _convert_content(
|
||||
and tool_call.tool_name
|
||||
in [
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
]
|
||||
@@ -470,7 +486,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input={},
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
@@ -532,13 +548,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="server_tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input={},
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(
|
||||
response.content_block,
|
||||
(
|
||||
WebSearchToolResultBlock,
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
),
|
||||
@@ -594,13 +611,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
current_tool_block = None
|
||||
continue
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_tool_block["input"] = tool_args
|
||||
current_tool_block["input"] |= tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_block["id"],
|
||||
tool_name=current_tool_block["name"],
|
||||
tool_args=tool_args,
|
||||
tool_args=current_tool_block["input"],
|
||||
external=current_tool_block["type"] == "server_tool_use",
|
||||
)
|
||||
]
|
||||
@@ -735,19 +752,34 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
]
|
||||
|
||||
if options.get(CONF_CODE_EXECUTION):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
name="code_execution",
|
||||
type="code_execution_20250825",
|
||||
),
|
||||
)
|
||||
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
|
||||
if model.startswith(
|
||||
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
|
||||
) or not options.get(CONF_WEB_SEARCH):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
name="code_execution",
|
||||
type="code_execution_20250825",
|
||||
),
|
||||
)
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
type="web_search_20250305",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
if model.startswith(
|
||||
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
|
||||
) or not options.get(CONF_CODE_EXECUTION):
|
||||
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
|
||||
WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
type="web_search_20250305",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
)
|
||||
else:
|
||||
web_search = WebSearchTool20260209Param(
|
||||
name="web_search",
|
||||
type="web_search_20260209",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
web_search["user_location"] = {
|
||||
"type": "approximate",
|
||||
|
||||
@@ -66,7 +66,7 @@ rules:
|
||||
comment: |
|
||||
To write something about what models we support.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The Arris TG2492LG component."""
|
||||
"""The Arris TG2492LG integration."""
|
||||
|
||||
@@ -124,6 +124,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"battery",
|
||||
"calendar",
|
||||
"climate",
|
||||
"counter",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"door",
|
||||
@@ -193,6 +194,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"window",
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The bitcoin component."""
|
||||
"""The Bitcoin integration."""
|
||||
|
||||
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Diagnostics support for the Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry
|
||||
|
||||
SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"})
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: CasperGlowConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
service_info = bluetooth.async_last_service_info(
|
||||
hass, coordinator.device.address, connectable=True
|
||||
)
|
||||
|
||||
return {
|
||||
"service_info": async_redact_data(
|
||||
service_info.as_dict() if service_info else None,
|
||||
SERVICE_INFO_TO_REDACT,
|
||||
),
|
||||
}
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycasperglow"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pycasperglow==1.1.0"]
|
||||
"requirements": ["pycasperglow==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No network discovery.
|
||||
|
||||
15
homeassistant/components/counter/condition.py
Normal file
15
homeassistant/components/counter/condition.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Provides conditions for counters."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
|
||||
|
||||
DOMAIN = "counter"
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_value": make_entity_numerical_condition(DOMAIN),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the conditions for counters."""
|
||||
return CONDITIONS
|
||||
25
homeassistant/components/counter/conditions.yaml
Normal file
25
homeassistant/components/counter/conditions.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
is_value:
|
||||
target:
|
||||
entity:
|
||||
- domain: counter
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
threshold:
|
||||
required: true
|
||||
selector:
|
||||
numeric_threshold:
|
||||
entity:
|
||||
- domain: counter
|
||||
- domain: input_number
|
||||
- domain: number
|
||||
mode: is
|
||||
number:
|
||||
mode: box
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_value": {
|
||||
"condition": "mdi:counter"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"decrement": {
|
||||
"service": "mdi:numeric-negative-1"
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
"trigger_behavior_description": "The behavior of the targeted counters to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_value": {
|
||||
"description": "Tests the value of one or more counters.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "How the state should match on the targeted counters.",
|
||||
"name": "Behavior"
|
||||
},
|
||||
"threshold": {
|
||||
"description": "What to test for and threshold values.",
|
||||
"name": "Threshold"
|
||||
}
|
||||
},
|
||||
"name": "Counter value"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "[%key:component::counter::title%]",
|
||||
@@ -30,6 +46,12 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -45,6 +45,13 @@ SUPPORT_FLAGS_HEATER = (
|
||||
)
|
||||
|
||||
|
||||
def _operation_mode_to_ha(mode: WaterHeaterOperationMode | None) -> str:
|
||||
"""Translate an EcoNet operation mode to a Home Assistant state."""
|
||||
if mode in (None, WaterHeaterOperationMode.VACATION):
|
||||
return STATE_OFF
|
||||
return ECONET_STATE_TO_HA[mode]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EconetConfigEntry,
|
||||
@@ -80,26 +87,22 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
|
||||
@property
|
||||
def current_operation(self) -> str:
|
||||
"""Return current operation."""
|
||||
econet_mode = self.water_heater.mode
|
||||
_current_op = STATE_OFF
|
||||
if econet_mode is not None:
|
||||
_current_op = ECONET_STATE_TO_HA[econet_mode]
|
||||
|
||||
return _current_op
|
||||
return _operation_mode_to_ha(self.water_heater.mode)
|
||||
|
||||
@property
|
||||
def operation_list(self) -> list[str]:
|
||||
"""List of available operation modes."""
|
||||
econet_modes = self.water_heater.modes
|
||||
operation_modes = set()
|
||||
for mode in econet_modes:
|
||||
if (
|
||||
mode is not WaterHeaterOperationMode.UNKNOWN
|
||||
and mode is not WaterHeaterOperationMode.VACATION
|
||||
):
|
||||
ha_mode = ECONET_STATE_TO_HA[mode]
|
||||
operation_modes.add(ha_mode)
|
||||
return list(operation_modes)
|
||||
return list(
|
||||
dict.fromkeys(
|
||||
ECONET_STATE_TO_HA[mode]
|
||||
for mode in self.water_heater.modes
|
||||
if mode
|
||||
not in (
|
||||
WaterHeaterOperationMode.UNKNOWN,
|
||||
WaterHeaterOperationMode.VACATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> WaterHeaterEntityFeature:
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The fail2ban component."""
|
||||
"""The Fail2Ban integration."""
|
||||
|
||||
@@ -10,6 +10,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import DOMAIN, UPNP_AVAILABLE
|
||||
|
||||
@@ -40,6 +41,7 @@ class FingConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
ip=user_input[CONF_IP_ADDRESS],
|
||||
port=int(user_input[CONF_PORT]),
|
||||
key=user_input[CONF_API_KEY],
|
||||
client=get_async_client(self.hass),
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -11,6 +11,7 @@ import httpx
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, UPNP_AVAILABLE
|
||||
@@ -38,6 +39,7 @@ class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
|
||||
ip=config_entry.data[CONF_IP_ADDRESS],
|
||||
port=int(config_entry.data[CONF_PORT]),
|
||||
key=config_entry.data[CONF_API_KEY],
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
|
||||
update_interval = timedelta(seconds=30)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fing_agent_api==1.0.3"]
|
||||
"requirements": ["fing_agent_api==1.1.0"]
|
||||
}
|
||||
|
||||
@@ -68,5 +68,5 @@ rules:
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Fortinet FortiOS components."""
|
||||
"""Fortinet FortiOS integration."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support to use FortiOS device like FortiGate as device tracker.
|
||||
|
||||
This component is part of the device_tracker platform.
|
||||
This FortiOS integration provides a device_tracker platform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
34
homeassistant/components/freshr/diagnostics.py
Normal file
34
homeassistant/components/freshr/diagnostics.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Diagnostics support for Fresh-r."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FreshrConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: FreshrConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"devices": [
|
||||
dataclasses.asdict(device) for device in runtime_data.devices.data.values()
|
||||
],
|
||||
"readings": {
|
||||
device_id: dataclasses.asdict(coordinator.data)
|
||||
if coordinator.data is not None
|
||||
else None
|
||||
for device_id, coordinator in runtime_data.readings.items()
|
||||
},
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration connects to a cloud service; no local network discovery is possible.
|
||||
|
||||
@@ -34,23 +34,17 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations:
|
||||
status: exempt
|
||||
comment: no known limitations, yet
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: add the known supported devices
|
||||
docs-supported-functions:
|
||||
status: todo
|
||||
comment: need to be overhauled
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases:
|
||||
status: todo
|
||||
comment: need to be overhauled
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
from typing import Any, NamedTuple, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
@@ -41,35 +39,23 @@ from homeassistant.components.http import (
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_NAME,
|
||||
EVENT_CORE_CONFIG_UPDATE,
|
||||
HASSIO_USER_NAME,
|
||||
SERVER_PORT,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.core import Event, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery_flow,
|
||||
issue_registry as ir,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
# config_flow, diagnostics, system_health, and entity platforms are imported to
|
||||
# ensure other dependencies that wait for hassio are not waiting
|
||||
@@ -92,19 +78,7 @@ from .auth import async_setup_auth_view
|
||||
from .config import HassioConfig
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_ADDON,
|
||||
ATTR_ADDONS,
|
||||
ATTR_APP,
|
||||
ATTR_APPS,
|
||||
ATTR_COMPRESSED,
|
||||
ATTR_FOLDERS,
|
||||
ATTR_HOMEASSISTANT,
|
||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_REPOSITORIES,
|
||||
ATTR_SLUG,
|
||||
DATA_ADDONS_LIST,
|
||||
DATA_COMPONENT,
|
||||
DATA_CONFIG_STORE,
|
||||
@@ -118,7 +92,6 @@ from .const import (
|
||||
DATA_SUPERVISOR_INFO,
|
||||
DOMAIN,
|
||||
HASSIO_UPDATE_INTERVAL,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .coordinator import (
|
||||
HassioDataUpdateCoordinator,
|
||||
@@ -136,15 +109,11 @@ from .coordinator import (
|
||||
get_supervisor_stats,
|
||||
)
|
||||
from .discovery import async_setup_discovery_view
|
||||
from .handler import (
|
||||
HassIO,
|
||||
HassioAPIError,
|
||||
async_update_diagnostics,
|
||||
get_supervisor_client,
|
||||
)
|
||||
from .handler import HassIO, async_update_diagnostics, get_supervisor_client
|
||||
from .http import HassIOView
|
||||
from .ingress import async_setup_ingress_view
|
||||
from .issues import SupervisorIssues
|
||||
from .services import async_setup_services
|
||||
from .websocket_api import async_load_websocket_api
|
||||
|
||||
# Expose the future safe name now so integrations can use it
|
||||
@@ -190,23 +159,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
SERVICE_ADDON_START = "addon_start"
|
||||
SERVICE_ADDON_STOP = "addon_stop"
|
||||
SERVICE_ADDON_RESTART = "addon_restart"
|
||||
SERVICE_ADDON_STDIN = "addon_stdin"
|
||||
SERVICE_APP_START = "app_start"
|
||||
SERVICE_APP_STOP = "app_stop"
|
||||
SERVICE_APP_RESTART = "app_restart"
|
||||
SERVICE_APP_STDIN = "app_stdin"
|
||||
SERVICE_HOST_SHUTDOWN = "host_shutdown"
|
||||
SERVICE_HOST_REBOOT = "host_reboot"
|
||||
SERVICE_BACKUP_FULL = "backup_full"
|
||||
SERVICE_BACKUP_PARTIAL = "backup_partial"
|
||||
SERVICE_RESTORE_FULL = "restore_full"
|
||||
SERVICE_RESTORE_PARTIAL = "restore_partial"
|
||||
SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
DEPRECATION_URL = (
|
||||
"https://www.home-assistant.io/blog/2025/05/22/"
|
||||
@@ -214,148 +166,11 @@ DEPRECATION_URL = (
|
||||
)
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
value = VALID_ADDON_SLUG(value)
|
||||
hass = async_get_hass_or_none()
|
||||
|
||||
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
|
||||
raise vol.Invalid("Not a valid app slug")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_NO_DATA = vol.Schema({})
|
||||
|
||||
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
|
||||
|
||||
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
|
||||
|
||||
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_FULL = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
): cv.string,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
vol.Optional(ATTR_COMPRESSED): cv.boolean,
|
||||
vol.Optional(ATTR_LOCATION): vol.All(
|
||||
cv.string, lambda v: None if v == "/backup" else v
|
||||
),
|
||||
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_FULL = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SLUG): cv.slug,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG]
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_MOUNT_RELOAD = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
|
||||
selector.DeviceSelectorConfig(
|
||||
filter=selector.DeviceFilterSelectorConfig(
|
||||
integration=DOMAIN,
|
||||
model=SupervisorEntityModel.MOUNT,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_32_bit() -> bool:
|
||||
size = struct.calcsize("P")
|
||||
return size * 8 == 32
|
||||
|
||||
|
||||
class APIEndpointSettings(NamedTuple):
|
||||
"""Settings for API endpoint."""
|
||||
|
||||
command: str
|
||||
schema: vol.Schema
|
||||
timeout: int | None = 60
|
||||
pass_data: bool = False
|
||||
|
||||
|
||||
MAP_SERVICE_API = {
|
||||
# Legacy addon services
|
||||
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STDIN: APIEndpointSettings(
|
||||
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
|
||||
),
|
||||
# New app services
|
||||
SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP),
|
||||
SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP),
|
||||
SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP),
|
||||
SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN),
|
||||
SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA),
|
||||
SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA),
|
||||
SERVICE_BACKUP_FULL: APIEndpointSettings(
|
||||
"/backups/new/full",
|
||||
SCHEMA_BACKUP_FULL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
SERVICE_BACKUP_PARTIAL: APIEndpointSettings(
|
||||
"/backups/new/partial",
|
||||
SCHEMA_BACKUP_PARTIAL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
SERVICE_RESTORE_FULL: APIEndpointSettings(
|
||||
"/backups/{slug}/restore/full",
|
||||
SCHEMA_RESTORE_FULL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
SERVICE_RESTORE_PARTIAL: APIEndpointSettings(
|
||||
"/backups/{slug}/restore/partial",
|
||||
SCHEMA_RESTORE_PARTIAL,
|
||||
None,
|
||||
True,
|
||||
),
|
||||
}
|
||||
|
||||
HARDWARE_INTEGRATIONS = {
|
||||
"green": "homeassistant_green",
|
||||
"odroid-c2": "hardkernel",
|
||||
@@ -397,7 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
|
||||
host = os.environ["SUPERVISOR"]
|
||||
websession = async_get_clientsession(hass)
|
||||
hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host)
|
||||
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
|
||||
supervisor_client = get_supervisor_client(hass)
|
||||
|
||||
try:
|
||||
@@ -510,74 +325,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
|
||||
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
|
||||
|
||||
async def async_service_handler(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
api_endpoint = MAP_SERVICE_API[service.service]
|
||||
|
||||
data = service.data.copy()
|
||||
addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None)
|
||||
slug = data.pop(ATTR_SLUG, None)
|
||||
|
||||
if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None):
|
||||
data[ATTR_ADDONS] = addons
|
||||
|
||||
payload = None
|
||||
|
||||
# Pass data to Hass.io API
|
||||
if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN):
|
||||
payload = data[ATTR_INPUT]
|
||||
elif api_endpoint.pass_data:
|
||||
payload = data
|
||||
|
||||
# Call API
|
||||
# The exceptions are logged properly in hassio.send_command
|
||||
with suppress(HassioAPIError):
|
||||
await hassio.send_command(
|
||||
api_endpoint.command.format(addon=addon, slug=slug),
|
||||
payload=payload,
|
||||
timeout=api_endpoint.timeout,
|
||||
)
|
||||
|
||||
for service, settings in MAP_SERVICE_API.items():
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_service_handler, schema=settings.schema
|
||||
)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
async def async_mount_reload(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
coordinator: HassioDataUpdateCoordinator | None = None
|
||||
|
||||
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_unknown_device_id",
|
||||
)
|
||||
|
||||
if (
|
||||
device.name is None
|
||||
or device.model != SupervisorEntityModel.MOUNT
|
||||
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
|
||||
or coordinator.entry_id not in device.config_entries
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_invalid_device",
|
||||
)
|
||||
|
||||
try:
|
||||
await supervisor_client.mounts.reload_mount(device.name)
|
||||
except SupervisorError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_error",
|
||||
translation_placeholders={"name": device.name, "error": str(error)},
|
||||
) from error
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
|
||||
)
|
||||
# Register services
|
||||
async_setup_services(hass, supervisor_client)
|
||||
|
||||
async def update_info_data(_: datetime | None = None) -> None:
|
||||
"""Update last available supervisor information."""
|
||||
|
||||
@@ -26,7 +26,7 @@ from aiohasupervisor.models import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .handler import HassioAPIError, get_supervisor_client
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]]
|
||||
type _ReturnFuncType[_T, **_P, _R] = Callable[
|
||||
@@ -36,18 +36,15 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[
|
||||
|
||||
def api_error[_AddonManagerT: AddonManager, **_P, _R](
|
||||
error_message: str,
|
||||
*,
|
||||
expected_error_type: type[HassioAPIError | SupervisorError] | None = None,
|
||||
) -> Callable[
|
||||
[_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R]
|
||||
]:
|
||||
"""Handle HassioAPIError and raise a specific AddonError."""
|
||||
error_type = expected_error_type or (HassioAPIError, SupervisorError)
|
||||
"""Handle SupervisorError and raise a specific AddonError."""
|
||||
|
||||
def handle_hassio_api_error(
|
||||
def handle_supervisor_error(
|
||||
func: _FuncType[_AddonManagerT, _P, _R],
|
||||
) -> _ReturnFuncType[_AddonManagerT, _P, _R]:
|
||||
"""Handle a HassioAPIError."""
|
||||
"""Handle a SupervisorError."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
@@ -56,7 +53,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
|
||||
"""Wrap an add-on manager method."""
|
||||
try:
|
||||
return_value = await func(self, *args, **kwargs)
|
||||
except error_type as err:
|
||||
except SupervisorError as err:
|
||||
raise AddonError(
|
||||
f"{error_message.format(addon_name=self.addon_name)}: {err}"
|
||||
) from err
|
||||
@@ -65,7 +62,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
|
||||
|
||||
return wrapper
|
||||
|
||||
return handle_hassio_api_error
|
||||
return handle_supervisor_error
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -128,10 +125,7 @@ class AddonManager:
|
||||
)
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to get the {addon_name} app discovery info",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to get the {addon_name} app discovery info")
|
||||
async def async_get_addon_discovery_info(self) -> dict:
|
||||
"""Return add-on discovery info."""
|
||||
discovery_info = next(
|
||||
@@ -148,10 +142,7 @@ class AddonManager:
|
||||
|
||||
return discovery_info.config
|
||||
|
||||
@api_error(
|
||||
"Failed to get the {addon_name} app info",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to get the {addon_name} app info")
|
||||
async def async_get_addon_info(self) -> AddonInfo:
|
||||
"""Return and cache manager add-on info."""
|
||||
addon_store_info = await self._supervisor_client.store.addon_info(
|
||||
@@ -199,19 +190,14 @@ class AddonManager:
|
||||
version=addon_info.version,
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to set the {addon_name} app options",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to set the {addon_name} app options")
|
||||
async def async_set_addon_options(self, config: dict) -> None:
|
||||
"""Set manager add-on options."""
|
||||
await self._supervisor_client.addons.set_addon_options(
|
||||
self.addon_slug, AddonsOptions(config=config)
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to install the {addon_name} app")
|
||||
async def async_install_addon(self) -> None:
|
||||
"""Install the managed add-on."""
|
||||
try:
|
||||
@@ -221,10 +207,7 @@ class AddonManager:
|
||||
f"{self.addon_name} app is not available: {err!s}"
|
||||
) from None
|
||||
|
||||
@api_error(
|
||||
"Failed to uninstall the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to uninstall the {addon_name} app")
|
||||
async def async_uninstall_addon(self) -> None:
|
||||
"""Uninstall the managed add-on."""
|
||||
await self._supervisor_client.addons.uninstall_addon(self.addon_slug)
|
||||
@@ -259,31 +242,22 @@ class AddonManager:
|
||||
self.addon_slug, StoreAddonUpdate(backup=False)
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to start the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to start the {addon_name} app")
|
||||
async def async_start_addon(self) -> None:
|
||||
"""Start the managed add-on."""
|
||||
await self._supervisor_client.addons.start_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to restart the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to restart the {addon_name} app")
|
||||
async def async_restart_addon(self) -> None:
|
||||
"""Restart the managed add-on."""
|
||||
await self._supervisor_client.addons.restart_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to stop the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
@api_error("Failed to stop the {addon_name} app")
|
||||
async def async_stop_addon(self) -> None:
|
||||
"""Stop the managed add-on."""
|
||||
await self._supervisor_client.addons.stop_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to create a backup of the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
@api_error("Failed to create a backup of the {addon_name} app")
|
||||
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
|
||||
"""Create a partial backup of the managed add-on."""
|
||||
if addon_info:
|
||||
|
||||
439
homeassistant/components/hassio/services.py
Normal file
439
homeassistant/components/hassio/services.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""Set up Supervisor services."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorClient, SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
FullBackupOptions,
|
||||
FullRestoreOptions,
|
||||
PartialBackupOptions,
|
||||
PartialRestoreOptions,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.util.dt import now
|
||||
|
||||
from .const import (
|
||||
ADDONS_COORDINATOR,
|
||||
ATTR_ADDON,
|
||||
ATTR_ADDONS,
|
||||
ATTR_APP,
|
||||
ATTR_APPS,
|
||||
ATTR_COMPRESSED,
|
||||
ATTR_FOLDERS,
|
||||
ATTR_HOMEASSISTANT,
|
||||
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
|
||||
ATTR_INPUT,
|
||||
ATTR_LOCATION,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_SLUG,
|
||||
DOMAIN,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
from .coordinator import HassioDataUpdateCoordinator, get_addons_info
|
||||
|
||||
SERVICE_ADDON_START = "addon_start"
|
||||
SERVICE_ADDON_STOP = "addon_stop"
|
||||
SERVICE_ADDON_RESTART = "addon_restart"
|
||||
SERVICE_ADDON_STDIN = "addon_stdin"
|
||||
SERVICE_APP_START = "app_start"
|
||||
SERVICE_APP_STOP = "app_stop"
|
||||
SERVICE_APP_RESTART = "app_restart"
|
||||
SERVICE_APP_STDIN = "app_stdin"
|
||||
SERVICE_HOST_SHUTDOWN = "host_shutdown"
|
||||
SERVICE_HOST_REBOOT = "host_reboot"
|
||||
SERVICE_BACKUP_FULL = "backup_full"
|
||||
SERVICE_BACKUP_PARTIAL = "backup_partial"
|
||||
SERVICE_RESTORE_FULL = "restore_full"
|
||||
SERVICE_RESTORE_PARTIAL = "restore_partial"
|
||||
SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
value = VALID_ADDON_SLUG(value)
|
||||
hass = async_get_hass_or_none()
|
||||
|
||||
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
|
||||
raise vol.Invalid("Not a valid app slug")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA_NO_DATA = vol.Schema({})
|
||||
|
||||
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
|
||||
|
||||
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
|
||||
|
||||
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
|
||||
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_FULL = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
): cv.string,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
vol.Optional(ATTR_COMPRESSED): cv.boolean,
|
||||
vol.Optional(ATTR_LOCATION): vol.All(
|
||||
cv.string, lambda v: None if v == "/backup" else v
|
||||
),
|
||||
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_FULL = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SLUG): cv.slug,
|
||||
vol.Optional(ATTR_PASSWORD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
# Legacy "addons", "apps" is preferred
|
||||
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SCHEMA_MOUNT_RELOAD = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
|
||||
selector.DeviceSelectorConfig(
|
||||
filter=selector.DeviceFilterSelectorConfig(
|
||||
integration=DOMAIN,
|
||||
model=SupervisorEntityModel.MOUNT,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register the Supervisor services."""
|
||||
async_register_app_services(hass, supervisor_client)
|
||||
async_register_host_services(hass, supervisor_client)
|
||||
async_register_backup_restore_services(hass, supervisor_client)
|
||||
async_register_network_storage_services(hass, supervisor_client)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_app_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register app services."""
|
||||
simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
|
||||
SERVICE_APP_START: ("start", supervisor_client.addons.start_addon),
|
||||
SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon),
|
||||
SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon),
|
||||
}
|
||||
|
||||
async def async_simple_app_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles app services which only take a slug and have no response."""
|
||||
action, api_method = simple_app_services[service.service]
|
||||
app_slug = service.data[ATTR_APP]
|
||||
|
||||
try:
|
||||
await api_method(app_slug)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to {action} app {app_slug}: {err}"
|
||||
) from err
|
||||
|
||||
for service in simple_app_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP
|
||||
)
|
||||
|
||||
async def async_app_stdin_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles app stdin service."""
|
||||
app_slug = service.data[ATTR_APP]
|
||||
data: dict | str = service.data[ATTR_INPUT]
|
||||
|
||||
# For backwards compatibility the payload here must be valid json
|
||||
# This is sensible when a dictionary is provided, it must be serialized
|
||||
# If user provides a string though, we wrap it in quotes before encoding
|
||||
# This is purely for legacy reasons, Supervisor has no json requirement
|
||||
# Supervisor just hands the raw request as binary to the container
|
||||
data = json.dumps(data)
|
||||
payload = data.encode(encoding="utf-8")
|
||||
|
||||
try:
|
||||
await supervisor_client.addons.write_addon_stdin(app_slug, payload)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to write stdin to app {app_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_APP_STDIN,
|
||||
async_app_stdin_service_handler,
|
||||
schema=SCHEMA_APP_STDIN,
|
||||
)
|
||||
|
||||
# LEGACY - Register equivalent addon services for compatibility
|
||||
simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
|
||||
SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon),
|
||||
SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon),
|
||||
SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon),
|
||||
}
|
||||
|
||||
async def async_simple_addon_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles addon services which only take a slug and have no response."""
|
||||
action, api_method = simple_addon_services[service.service]
|
||||
addon_slug = service.data[ATTR_ADDON]
|
||||
|
||||
try:
|
||||
await api_method(addon_slug)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to {action} app {addon_slug}: {err}"
|
||||
) from err
|
||||
|
||||
for service in simple_addon_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON
|
||||
)
|
||||
|
||||
async def async_addon_stdin_service_handler(service: ServiceCall) -> None:
|
||||
"""Handles addon stdin service."""
|
||||
addon_slug = service.data[ATTR_ADDON]
|
||||
data: dict | str = service.data[ATTR_INPUT]
|
||||
|
||||
# See explanation for why we make strings into json in async_app_stdin_service_handler
|
||||
data = json.dumps(data)
|
||||
payload = data.encode(encoding="utf-8")
|
||||
|
||||
try:
|
||||
await supervisor_client.addons.write_addon_stdin(addon_slug, payload)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to write stdin to app {addon_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADDON_STDIN,
|
||||
async_addon_stdin_service_handler,
|
||||
schema=SCHEMA_ADDON_STDIN,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_host_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register host services."""
|
||||
simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = {
|
||||
SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot),
|
||||
SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown),
|
||||
}
|
||||
|
||||
async def async_simple_host_service_handler(service: ServiceCall) -> None:
|
||||
"""Handler for host services that take no input and return no response."""
|
||||
action, api_method = simple_host_services[service.service]
|
||||
try:
|
||||
await api_method()
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(f"Failed to {action} the host: {err}") from err
|
||||
|
||||
for service in simple_host_services:
|
||||
hass.services.async_register(
|
||||
DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_restore_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register backup and restore services."""
|
||||
|
||||
async def async_full_backup_service_handler(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create full backup service. Returns the new backup's ID."""
|
||||
options = FullBackupOptions(**service.data)
|
||||
try:
|
||||
backup = await supervisor_client.backups.full_backup(options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to create full backup {options.name}: {err}"
|
||||
) from err
|
||||
|
||||
return {"backup": backup.slug}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_BACKUP_FULL,
|
||||
async_full_backup_service_handler,
|
||||
schema=SCHEMA_BACKUP_FULL,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
async def async_partial_backup_service_handler(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create partial backup service. Returns the new backup's ID."""
|
||||
data = service.data.copy()
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
options = PartialBackupOptions(**data)
|
||||
|
||||
try:
|
||||
backup = await supervisor_client.backups.partial_backup(options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to create partial backup {options.name}: {err}"
|
||||
) from err
|
||||
|
||||
return {"backup": backup.slug}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_BACKUP_PARTIAL,
|
||||
async_partial_backup_service_handler,
|
||||
schema=SCHEMA_BACKUP_PARTIAL,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
async def async_full_restore_service_handler(service: ServiceCall) -> None:
|
||||
"""Handler for full restore service."""
|
||||
backup_slug = service.data[ATTR_SLUG]
|
||||
options: FullRestoreOptions | None = None
|
||||
if ATTR_PASSWORD in service.data:
|
||||
options = FullRestoreOptions(password=service.data[ATTR_PASSWORD])
|
||||
|
||||
try:
|
||||
await supervisor_client.backups.full_restore(backup_slug, options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to full restore from backup {backup_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE_FULL,
|
||||
async_full_restore_service_handler,
|
||||
schema=SCHEMA_RESTORE_FULL,
|
||||
)
|
||||
|
||||
async def async_partial_restore_service_handler(service: ServiceCall) -> None:
|
||||
"""Handler for partial restore service."""
|
||||
data = service.data.copy()
|
||||
backup_slug = data.pop(ATTR_SLUG)
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
options = PartialRestoreOptions(**data)
|
||||
|
||||
try:
|
||||
await supervisor_client.backups.partial_restore(backup_slug, options)
|
||||
except SupervisorError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to partial restore from backup {backup_slug}: {err}"
|
||||
) from err
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE_PARTIAL,
|
||||
async_partial_restore_service_handler,
|
||||
schema=SCHEMA_RESTORE_PARTIAL,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_network_storage_services(
|
||||
hass: HomeAssistant, supervisor_client: SupervisorClient
|
||||
) -> None:
|
||||
"""Register network storage (or mount) services."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
async def async_mount_reload(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
coordinator: HassioDataUpdateCoordinator | None = None
|
||||
|
||||
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_unknown_device_id",
|
||||
)
|
||||
|
||||
if (
|
||||
device.name is None
|
||||
or device.model != SupervisorEntityModel.MOUNT
|
||||
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
|
||||
or coordinator.entry_id not in device.config_entries
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_invalid_device",
|
||||
)
|
||||
|
||||
try:
|
||||
await supervisor_client.mounts.reload_mount(device.name)
|
||||
except SupervisorError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mount_reload_error",
|
||||
translation_placeholders={"name": device.name, "error": str(error)},
|
||||
) from error
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
|
||||
)
|
||||
@@ -18,7 +18,6 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
|
||||
from . import HassioAPIError
|
||||
from .config import HassioUpdateParametersDict
|
||||
from .const import (
|
||||
ATTR_DATA,
|
||||
@@ -40,6 +39,7 @@ from .const import (
|
||||
WS_TYPE_SUBSCRIBE,
|
||||
)
|
||||
from .coordinator import get_addons_list
|
||||
from .handler import HassioAPIError
|
||||
from .update_helper import update_addon, update_core
|
||||
|
||||
SCHEMA_WEBSOCKET_EVENT = vol.Schema(
|
||||
|
||||
@@ -4,14 +4,23 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [Platform.EVENT, Platform.NOTIFY]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the HTML5 services."""
|
||||
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up HTML5 from a config entry."""
|
||||
hass.async_create_task(
|
||||
|
||||
@@ -11,5 +11,18 @@ ATTR_VAPID_EMAIL = "vapid_email"
|
||||
REGISTRATIONS_FILE = "html5_push_registrations.conf"
|
||||
|
||||
ATTR_ACTION = "action"
|
||||
ATTR_ACTIONS = "actions"
|
||||
ATTR_BADGE = "badge"
|
||||
ATTR_DATA = "data"
|
||||
ATTR_DIR = "dir"
|
||||
ATTR_ICON = "icon"
|
||||
ATTR_IMAGE = "image"
|
||||
ATTR_LANG = "lang"
|
||||
ATTR_RENOTIFY = "renotify"
|
||||
ATTR_REQUIRE_INTERACTION = "require_interaction"
|
||||
ATTR_SILENT = "silent"
|
||||
ATTR_TAG = "tag"
|
||||
ATTR_TIMESTAMP = "timestamp"
|
||||
ATTR_TTL = "ttl"
|
||||
ATTR_URGENCY = "urgency"
|
||||
ATTR_VIBRATE = "vibrate"
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"services": {
|
||||
"dismiss": {
|
||||
"service": "mdi:bell-off"
|
||||
},
|
||||
"send_message": {
|
||||
"service": "mdi:message-arrow-right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
homeassistant/components/html5/issue.py
Normal file
31
homeassistant/components/html5/issue.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Issues for HTML5 integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def deprecated_notify_action_call(
|
||||
hass: HomeAssistant, target: list[str] | None
|
||||
) -> None:
|
||||
"""Deprecated action call."""
|
||||
|
||||
action = (
|
||||
f"notify.html5_{slugify(target[0])}"
|
||||
if target and len(target) == 1
|
||||
else "notify.html5"
|
||||
)
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_notify_action_{action}",
|
||||
breaks_in_ha_version="2026.11.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_notify_action",
|
||||
translation_placeholders={"action": action},
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "html5",
|
||||
"name": "HTML5 Push Notifications",
|
||||
"codeowners": ["@alexyao2015"],
|
||||
"codeowners": ["@alexyao2015", "@tr4nt0r"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/html5",
|
||||
|
||||
@@ -47,7 +47,11 @@ from homeassistant.util.json import load_json_object
|
||||
|
||||
from .const import (
|
||||
ATTR_ACTION,
|
||||
ATTR_ACTIONS,
|
||||
ATTR_REQUIRE_INTERACTION,
|
||||
ATTR_TAG,
|
||||
ATTR_TIMESTAMP,
|
||||
ATTR_TTL,
|
||||
ATTR_VAPID_EMAIL,
|
||||
ATTR_VAPID_PRV_KEY,
|
||||
ATTR_VAPID_PUB_KEY,
|
||||
@@ -56,6 +60,7 @@ from .const import (
|
||||
SERVICE_DISMISS,
|
||||
)
|
||||
from .entity import HTML5Entity, Registration
|
||||
from .issue import deprecated_notify_action_call
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,13 +74,11 @@ ATTR_AUTH = "auth"
|
||||
ATTR_P256DH = "p256dh"
|
||||
ATTR_EXPIRATIONTIME = "expirationTime"
|
||||
|
||||
ATTR_ACTIONS = "actions"
|
||||
ATTR_TYPE = "type"
|
||||
ATTR_URL = "url"
|
||||
ATTR_DISMISS = "dismiss"
|
||||
ATTR_PRIORITY = "priority"
|
||||
DEFAULT_PRIORITY = "normal"
|
||||
ATTR_TTL = "ttl"
|
||||
DEFAULT_TTL = 86400
|
||||
|
||||
DEFAULT_BADGE = "/static/images/notification-badge.png"
|
||||
@@ -465,6 +468,9 @@ class HTML5NotificationService(BaseNotificationService):
|
||||
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to a user."""
|
||||
|
||||
deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET))
|
||||
|
||||
tag = str(uuid.uuid4())
|
||||
payload: dict[str, Any] = {
|
||||
"badge": DEFAULT_BADGE,
|
||||
@@ -605,32 +611,53 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
|
||||
_key = "device"
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message to a device."""
|
||||
timestamp = int(time.time())
|
||||
tag = str(uuid.uuid4())
|
||||
"""Send a message to a device via notify.send_message action."""
|
||||
await self._webpush(
|
||||
title=title or ATTR_TITLE_DEFAULT,
|
||||
message=message,
|
||||
badge=DEFAULT_BADGE,
|
||||
icon=DEFAULT_ICON,
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"badge": DEFAULT_BADGE,
|
||||
"body": message,
|
||||
"icon": DEFAULT_ICON,
|
||||
ATTR_TAG: tag,
|
||||
ATTR_TITLE: title or ATTR_TITLE_DEFAULT,
|
||||
"timestamp": timestamp * 1000,
|
||||
ATTR_DATA: {
|
||||
ATTR_JWT: add_jwt(
|
||||
timestamp,
|
||||
self.target,
|
||||
tag,
|
||||
self.registration["subscription"]["keys"]["auth"],
|
||||
)
|
||||
},
|
||||
}
|
||||
async def send_push_notification(self, **kwargs: Any) -> None:
|
||||
"""Send a message to a device via html5.send_message action."""
|
||||
await self._webpush(**kwargs)
|
||||
self._async_record_notification()
|
||||
|
||||
async def _webpush(
|
||||
self,
|
||||
message: str | None = None,
|
||||
timestamp: datetime | None = None,
|
||||
ttl: timedelta | None = None,
|
||||
urgency: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Shared internal helper to push messages."""
|
||||
payload: dict[str, Any] = kwargs
|
||||
|
||||
if message is not None:
|
||||
payload["body"] = message
|
||||
|
||||
payload.setdefault(ATTR_TAG, str(uuid.uuid4()))
|
||||
ts = int(timestamp.timestamp()) if timestamp else int(time.time())
|
||||
payload[ATTR_TIMESTAMP] = ts * 1000
|
||||
|
||||
if ATTR_REQUIRE_INTERACTION in payload:
|
||||
payload["requireInteraction"] = payload.pop(ATTR_REQUIRE_INTERACTION)
|
||||
|
||||
payload.setdefault(ATTR_DATA, {})
|
||||
payload[ATTR_DATA][ATTR_JWT] = add_jwt(
|
||||
ts,
|
||||
self.target,
|
||||
payload[ATTR_TAG],
|
||||
self.registration["subscription"]["keys"]["auth"],
|
||||
)
|
||||
|
||||
endpoint = urlparse(self.registration["subscription"]["endpoint"])
|
||||
vapid_claims = {
|
||||
"sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}",
|
||||
"aud": f"{endpoint.scheme}://{endpoint.netloc}",
|
||||
"exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
|
||||
"exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -639,6 +666,8 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
|
||||
json.dumps(payload),
|
||||
self.config_entry.data[ATTR_VAPID_PRV_KEY],
|
||||
vapid_claims,
|
||||
ttl=int(ttl.total_seconds()) if ttl is not None else DEFAULT_TTL,
|
||||
headers={"Urgency": urgency} if urgency else None,
|
||||
aiohttp_session=self.session,
|
||||
)
|
||||
cast(ClientResponse, response).raise_for_status()
|
||||
|
||||
82
homeassistant/components/html5/services.py
Normal file
82
homeassistant/components/html5/services.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Service registration for HTML5 integration."""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_TITLE,
|
||||
ATTR_TITLE_DEFAULT,
|
||||
DOMAIN as NOTIFY_DOMAIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import (
|
||||
ATTR_ACTION,
|
||||
ATTR_ACTIONS,
|
||||
ATTR_BADGE,
|
||||
ATTR_DIR,
|
||||
ATTR_ICON,
|
||||
ATTR_IMAGE,
|
||||
ATTR_LANG,
|
||||
ATTR_RENOTIFY,
|
||||
ATTR_REQUIRE_INTERACTION,
|
||||
ATTR_SILENT,
|
||||
ATTR_TAG,
|
||||
ATTR_TIMESTAMP,
|
||||
ATTR_TTL,
|
||||
ATTR_URGENCY,
|
||||
ATTR_VIBRATE,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
SERVICE_SEND_MESSAGE = "send_message"
|
||||
|
||||
SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string,
|
||||
vol.Optional(ATTR_MESSAGE): cv.string,
|
||||
vol.Optional(ATTR_DIR): vol.In({"auto", "ltr", "rtl"}),
|
||||
vol.Optional(ATTR_ICON): cv.string,
|
||||
vol.Optional(ATTR_BADGE): cv.string,
|
||||
vol.Optional(ATTR_IMAGE): cv.string,
|
||||
vol.Optional(ATTR_TAG): cv.string,
|
||||
vol.Exclusive(ATTR_VIBRATE, "silent_xor_vibrate"): vol.All(
|
||||
cv.ensure_list,
|
||||
[vol.All(vol.Coerce(int), vol.Range(min=0))],
|
||||
),
|
||||
vol.Optional(ATTR_TIMESTAMP): cv.datetime,
|
||||
vol.Optional(ATTR_LANG): cv.language,
|
||||
vol.Exclusive(ATTR_SILENT, "silent_xor_vibrate"): cv.boolean,
|
||||
vol.Optional(ATTR_RENOTIFY): cv.boolean,
|
||||
vol.Optional(ATTR_REQUIRE_INTERACTION): cv.boolean,
|
||||
vol.Optional(ATTR_URGENCY): vol.In({"normal", "high", "low"}),
|
||||
vol.Optional(ATTR_TTL): vol.All(cv.time_period, cv.positive_timedelta),
|
||||
vol.Optional(ATTR_ACTIONS): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
{
|
||||
vol.Required(ATTR_ACTION): cv.string,
|
||||
vol.Required(ATTR_TITLE): cv.string,
|
||||
vol.Optional(ATTR_ICON): cv.string,
|
||||
}
|
||||
],
|
||||
),
|
||||
vol.Optional(ATTR_DATA): dict,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for HTML5 integration."""
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
entity_domain=NOTIFY_DOMAIN,
|
||||
schema=SERVICE_SEND_MESSAGE_SCHEMA,
|
||||
func="send_push_notification",
|
||||
)
|
||||
@@ -8,3 +8,137 @@ dismiss:
|
||||
example: '{ "tag": "tagname" }'
|
||||
selector:
|
||||
object:
|
||||
send_message:
|
||||
target:
|
||||
entity:
|
||||
domain: notify
|
||||
integration: html5
|
||||
fields:
|
||||
title:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
example: Home Assistant
|
||||
default: Home Assistant
|
||||
message:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
example: Hello World
|
||||
icon:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: /static/icons/favicon-192x192.png
|
||||
badge:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: /static/images/notification-badge.png
|
||||
image:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: /static/images/image.jpg
|
||||
tag:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
example: message-group-1
|
||||
actions:
|
||||
selector:
|
||||
object:
|
||||
label_field: "action"
|
||||
description_field: "title"
|
||||
multiple: true
|
||||
translation_key: actions
|
||||
fields:
|
||||
action:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
title:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: '[{"action": "test-action", "title": "🆗 Click here!", "icon": "/images/action-1-128x128.png"}]'
|
||||
dir:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- auto
|
||||
- ltr
|
||||
- rtl
|
||||
mode: dropdown
|
||||
translation_key: dir
|
||||
example: auto
|
||||
renotify:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
example: true
|
||||
silent:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
example: true
|
||||
require_interaction:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
example: true
|
||||
vibrate:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
type: number
|
||||
suffix: ms
|
||||
example: "[125,75,125,275,200,275,125,75,125,275,200,600,200,600]"
|
||||
lang:
|
||||
required: false
|
||||
selector:
|
||||
language:
|
||||
example: es-419
|
||||
timestamp:
|
||||
required: false
|
||||
selector:
|
||||
datetime:
|
||||
example: "1970-01-01 00:00:00"
|
||||
ttl:
|
||||
required: false
|
||||
selector:
|
||||
duration:
|
||||
enable_day: true
|
||||
example: "{'days': 28}"
|
||||
urgency:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- low
|
||||
- normal
|
||||
- high
|
||||
mode: dropdown
|
||||
translation_key: urgency
|
||||
example: normal
|
||||
data:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
example: "{'customKey': 'customValue'}"
|
||||
|
||||
@@ -48,6 +48,44 @@
|
||||
"message": "Sending notification to {target} failed due to a request error"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_notify_action": {
|
||||
"description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `notify.send_message` or `html5.send_message` actions instead.",
|
||||
"title": "Detected use of deprecated action {action}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"actions": {
|
||||
"fields": {
|
||||
"action": {
|
||||
"description": "The identifier of the action. This will be sent back to Home Assistant when the user clicks the button.",
|
||||
"name": "Action identifier"
|
||||
},
|
||||
"icon": {
|
||||
"description": "URL of an image displayed as the icon for this button.",
|
||||
"name": "Icon"
|
||||
},
|
||||
"title": {
|
||||
"description": "The label of the button displayed to the user.",
|
||||
"name": "Title"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dir": {
|
||||
"options": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"ltr": "Left-to-right",
|
||||
"rtl": "Right-to-left"
|
||||
}
|
||||
},
|
||||
"urgency": {
|
||||
"options": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"normal": "[%key:common::state::normal%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"dismiss": {
|
||||
"description": "Dismisses an HTML5 notification.",
|
||||
@@ -62,6 +100,80 @@
|
||||
}
|
||||
},
|
||||
"name": "Dismiss"
|
||||
},
|
||||
"send_message": {
|
||||
"description": "Sends a message via HTML5 Push Notifications",
|
||||
"fields": {
|
||||
"actions": {
|
||||
"description": "Adds action buttons to the notification. When the user clicks a button, an event is sent back to Home Assistant. Amount of actions supported may vary between platforms.",
|
||||
"name": "Action buttons"
|
||||
},
|
||||
"badge": {
|
||||
"description": "URL or relative path of a small image to replace the browser icon on mobile platforms. Maximum size is 96px by 96px",
|
||||
"name": "Badge"
|
||||
},
|
||||
"data": {
|
||||
"description": "Additional custom key-value pairs to include in the payload of the push message. This can be used to include extra information that can be accessed in the notification events.",
|
||||
"name": "Extra data"
|
||||
},
|
||||
"dir": {
|
||||
"description": "The direction of the notification's text. Adopts the browser's language setting behavior by default.",
|
||||
"name": "Text direction"
|
||||
},
|
||||
"icon": {
|
||||
"description": "URL or relative path of an image to display as the main icon in the notification. Maximum size is 320px by 320px.",
|
||||
"name": "Icon"
|
||||
},
|
||||
"image": {
|
||||
"description": "URL or relative path of a larger image to display in the main body of the notification. Experimental support, may not be displayed on all platforms.",
|
||||
"name": "Image"
|
||||
},
|
||||
"lang": {
|
||||
"description": "The language of the notification's content.",
|
||||
"name": "Language"
|
||||
},
|
||||
"message": {
|
||||
"description": "The message body of the notification.",
|
||||
"name": "Message"
|
||||
},
|
||||
"renotify": {
|
||||
"description": "If enabled, the user will be alerted again (sound/vibration) when a notification with the same tag replaces a previous one.",
|
||||
"name": "Renotify"
|
||||
},
|
||||
"require_interaction": {
|
||||
"description": "If enabled, the notification will remain active until the user clicks or dismisses it, rather than automatically closing after a few seconds. This provides the same behavior on desktop as on mobile platforms.",
|
||||
"name": "Require interaction"
|
||||
},
|
||||
"silent": {
|
||||
"description": "If enabled, the notification will not play sounds or trigger vibration, regardless of the device's notification settings.",
|
||||
"name": "Silent"
|
||||
},
|
||||
"tag": {
|
||||
"description": "The identifier of the notification. Sending a new notification with the same tag will replace the existing one. If not specified, a unique tag will be generated for each notification.",
|
||||
"name": "Tag"
|
||||
},
|
||||
"timestamp": {
|
||||
"description": "The timestamp of the notification. By default, it uses the time when the notification is sent.",
|
||||
"name": "Timestamp"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title for your notification message.",
|
||||
"name": "Title"
|
||||
},
|
||||
"ttl": {
|
||||
"description": "Specifies how long the push service should retain the message if the user's browser or device is offline. After this period, the notification expires. A value of 0 means the notification is discarded immediately if the target is not connected. Defaults to 1 day.",
|
||||
"name": "Time to live"
|
||||
},
|
||||
"urgency": {
|
||||
"description": "Whether the push service should try to deliver the notification immediately or defer it in accordance with the user's power saving preferences.",
|
||||
"name": "Urgency"
|
||||
},
|
||||
"vibrate": {
|
||||
"description": "A vibration pattern to run with the notification. An array of integers representing alternating periods of vibration and silence in milliseconds. For example, [200, 100, 200] would vibrate for 200ms, pause for 100ms, then vibrate for another 200ms.",
|
||||
"name": "Vibration pattern"
|
||||
}
|
||||
},
|
||||
"name": "Send message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from huum.const import SaunaStatus
|
||||
@@ -18,12 +17,10 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
|
||||
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP, DOMAIN
|
||||
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
|
||||
from .entity import HuumBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@@ -113,5 +110,7 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
|
||||
try:
|
||||
await self.coordinator.huum.turn_on(temperature)
|
||||
except (ValueError, SafetyException) as err:
|
||||
_LOGGER.error(str(err))
|
||||
raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unable_to_turn_on",
|
||||
) from err
|
||||
|
||||
@@ -56,5 +56,6 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
|
||||
return await self.huum.status()
|
||||
except (Forbidden, NotAuthenticated) as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Could not log in to Huum with given credentials"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from err
|
||||
|
||||
@@ -62,7 +62,7 @@ rules:
|
||||
status: exempt
|
||||
comment: All entities are core functionality.
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
|
||||
@@ -45,5 +45,13 @@
|
||||
"name": "[%key:component::sensor::entity_component::humidity::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_failed": {
|
||||
"message": "Could not log in to Huum with the given credentials."
|
||||
},
|
||||
"unable_to_turn_on": {
|
||||
"message": "Unable to turn on the sauna."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,31 +73,45 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
|
||||
except HTTPError as error:
|
||||
raise UpdateFailed from error
|
||||
|
||||
try:
|
||||
# Fetch last hour of data
|
||||
for sensor in self.devices:
|
||||
# Fetch last hour of data
|
||||
for sensor in self.devices:
|
||||
try:
|
||||
data = await self.api.get_sensor_status(
|
||||
sensor=sensor,
|
||||
tz=self.hass.config.time_zone,
|
||||
)
|
||||
_LOGGER.debug("Got data: %s", data)
|
||||
except HTTPError as error:
|
||||
error_data = error.args[1] if len(error.args) > 1 else None
|
||||
if (
|
||||
isinstance(error_data, dict)
|
||||
and error_data.get("error") == "no_readings"
|
||||
):
|
||||
sensor.data = None
|
||||
_LOGGER.debug("No readings for %s", sensor.name)
|
||||
continue
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN, translation_key="update_error"
|
||||
) from error
|
||||
|
||||
if data_error := data.get("error"):
|
||||
if data_error == "no_readings":
|
||||
sensor.data = None
|
||||
_LOGGER.debug("No readings for %s", sensor.name)
|
||||
continue
|
||||
_LOGGER.debug("Error: %s", data_error)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN, translation_key="update_error"
|
||||
)
|
||||
_LOGGER.debug("Got data: %s", data)
|
||||
|
||||
sensor.data = data["data"]["current"]
|
||||
if data_error := data.get("error"):
|
||||
if data_error == "no_readings":
|
||||
sensor.data = None
|
||||
_LOGGER.debug("No readings for %s", sensor.name)
|
||||
continue
|
||||
_LOGGER.debug("Error: %s", data_error)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN, translation_key="update_error"
|
||||
)
|
||||
|
||||
except HTTPError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN, translation_key="update_error"
|
||||
) from error
|
||||
current_data = data.get("data", {}).get("current")
|
||||
if current_data is None:
|
||||
sensor.data = None
|
||||
_LOGGER.debug("No current data payload for %s", sensor.name)
|
||||
continue
|
||||
|
||||
sensor.data = current_data
|
||||
|
||||
# Verify that we have permission to read the sensors
|
||||
for sensor in self.devices:
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The linksys_smart component."""
|
||||
"""The Linksys Smart Wi-Fi integration."""
|
||||
|
||||
@@ -6,19 +6,14 @@ from meteofrance_api.client import MeteoFranceClient
|
||||
from meteofrance_api.helpers import is_valid_warning_department
|
||||
from requests import RequestException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
COORDINATOR_ALERT,
|
||||
COORDINATOR_FORECAST,
|
||||
COORDINATOR_RAIN,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import (
|
||||
MeteoFranceAlertUpdateCoordinator,
|
||||
MeteoFranceConfigEntry,
|
||||
MeteoFranceData,
|
||||
MeteoFranceForecastUpdateCoordinator,
|
||||
MeteoFranceRainUpdateCoordinator,
|
||||
)
|
||||
@@ -26,7 +21,7 @@ from .coordinator import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MeteoFranceConfigEntry) -> bool:
|
||||
"""Set up a Meteo-France account from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
@@ -91,25 +86,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
COORDINATOR_FORECAST: coordinator_forecast,
|
||||
}
|
||||
if coordinator_rain and coordinator_rain.last_update_success:
|
||||
hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain
|
||||
if coordinator_alert and coordinator_alert.last_update_success:
|
||||
hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert
|
||||
if coordinator_rain and not coordinator_rain.last_update_success:
|
||||
coordinator_rain = None
|
||||
if coordinator_alert and not coordinator_alert.last_update_success:
|
||||
coordinator_alert = None
|
||||
entry.runtime_data = MeteoFranceData(
|
||||
forecast_coordinator=coordinator_forecast,
|
||||
rain_coordinator=coordinator_rain,
|
||||
alert_coordinator=coordinator_alert,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: MeteoFranceConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]:
|
||||
department = hass.data[DOMAIN][entry.entry_id][
|
||||
COORDINATOR_FORECAST
|
||||
].data.position.get("dept")
|
||||
if entry.runtime_data.alert_coordinator:
|
||||
department = entry.runtime_data.forecast_coordinator.data.position.get("dept")
|
||||
hass.data[DOMAIN][department] = False
|
||||
_LOGGER.debug(
|
||||
(
|
||||
@@ -121,13 +118,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
if not hass.data[DOMAIN]:
|
||||
hass.data.pop(DOMAIN)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: MeteoFranceConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -23,9 +23,6 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "meteo_france"
|
||||
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
|
||||
COORDINATOR_FORECAST = "coordinator_forecast"
|
||||
COORDINATOR_RAIN = "coordinator_rain"
|
||||
COORDINATOR_ALERT = "coordinator_alert"
|
||||
ATTRIBUTION = "Data provided by Météo-France"
|
||||
MODEL = "Météo-France mobile API"
|
||||
MANUFACTURER = "Météo-France"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Support for Meteo-France weather data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
@@ -13,6 +16,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type MeteoFranceConfigEntry = ConfigEntry[MeteoFranceData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MeteoFranceData:
|
||||
"""Data for the Meteo-France integration."""
|
||||
|
||||
forecast_coordinator: MeteoFranceForecastUpdateCoordinator
|
||||
rain_coordinator: MeteoFranceRainUpdateCoordinator | None
|
||||
alert_coordinator: MeteoFranceAlertUpdateCoordinator | None
|
||||
|
||||
|
||||
SCAN_INTERVAL_RAIN = timedelta(minutes=5)
|
||||
SCAN_INTERVAL = timedelta(minutes=15)
|
||||
|
||||
@@ -20,12 +35,12 @@ SCAN_INTERVAL = timedelta(minutes=15)
|
||||
class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
|
||||
"""Coordinator for Meteo-France forecast data."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MeteoFranceConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MeteoFranceConfigEntry,
|
||||
client: MeteoFranceClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
@@ -50,12 +65,12 @@ class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
|
||||
class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
|
||||
"""Coordinator for Meteo-France rain data."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MeteoFranceConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MeteoFranceConfigEntry,
|
||||
client: MeteoFranceClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
@@ -80,12 +95,12 @@ class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
|
||||
class MeteoFranceAlertUpdateCoordinator(DataUpdateCoordinator[CurrentPhenomenons]):
|
||||
"""Coordinator for Meteo-France alert data."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MeteoFranceConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MeteoFranceConfigEntry,
|
||||
client: MeteoFranceClient,
|
||||
department: str,
|
||||
) -> None:
|
||||
|
||||
@@ -19,7 +19,6 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
UV_INDEX,
|
||||
@@ -41,18 +40,11 @@ from .const import (
|
||||
ATTR_NEXT_RAIN_1_HOUR_FORECAST,
|
||||
ATTR_NEXT_RAIN_DT_REF,
|
||||
ATTRIBUTION,
|
||||
COORDINATOR_ALERT,
|
||||
COORDINATOR_FORECAST,
|
||||
COORDINATOR_RAIN,
|
||||
DOMAIN,
|
||||
MANUFACTURER,
|
||||
MODEL,
|
||||
)
|
||||
from .coordinator import (
|
||||
MeteoFranceAlertUpdateCoordinator,
|
||||
MeteoFranceForecastUpdateCoordinator,
|
||||
MeteoFranceRainUpdateCoordinator,
|
||||
)
|
||||
from .coordinator import MeteoFranceAlertUpdateCoordinator, MeteoFranceConfigEntry
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -188,20 +180,13 @@ SENSOR_TYPES_PROBABILITY: tuple[MeteoFranceSensorEntityDescription, ...] = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MeteoFranceConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Meteo-France sensor platform."""
|
||||
data = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator_forecast: MeteoFranceForecastUpdateCoordinator = data[
|
||||
COORDINATOR_FORECAST
|
||||
]
|
||||
coordinator_rain: MeteoFranceRainUpdateCoordinator | None = data.get(
|
||||
COORDINATOR_RAIN
|
||||
)
|
||||
coordinator_alert: MeteoFranceAlertUpdateCoordinator | None = data.get(
|
||||
COORDINATOR_ALERT
|
||||
)
|
||||
coordinator_forecast = entry.runtime_data.forecast_coordinator
|
||||
coordinator_rain = entry.runtime_data.rain_coordinator
|
||||
coordinator_alert = entry.runtime_data.alert_coordinator
|
||||
|
||||
entities: list[MeteoFranceSensor[Any]] = [
|
||||
MeteoFranceSensor(coordinator_forecast, description)
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.components.weather import (
|
||||
WeatherEntity,
|
||||
WeatherEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_MODE,
|
||||
UnitOfPrecipitationDepth,
|
||||
@@ -35,14 +34,13 @@ from homeassistant.util import dt as dt_util
|
||||
from .const import (
|
||||
ATTRIBUTION,
|
||||
CONDITION_MAP,
|
||||
COORDINATOR_FORECAST,
|
||||
DOMAIN,
|
||||
FORECAST_MODE_DAILY,
|
||||
FORECAST_MODE_HOURLY,
|
||||
MANUFACTURER,
|
||||
MODEL,
|
||||
)
|
||||
from .coordinator import MeteoFranceForecastUpdateCoordinator
|
||||
from .coordinator import MeteoFranceConfigEntry, MeteoFranceForecastUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,13 +56,11 @@ def format_condition(condition: str, force_day: bool = False) -> str:
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MeteoFranceConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Meteo-France weather platform."""
|
||||
coordinator: MeteoFranceForecastUpdateCoordinator = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
][COORDINATOR_FORECAST]
|
||||
coordinator = entry.runtime_data.forecast_coordinator
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from motionblinds import AsyncMotionMulticast
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -14,32 +12,28 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from .const import (
|
||||
CONF_BLIND_TYPE_LIST,
|
||||
CONF_INTERFACE,
|
||||
CONF_WAIT_FOR_PUSH,
|
||||
DEFAULT_INTERFACE,
|
||||
DEFAULT_WAIT_FOR_PUSH,
|
||||
DOMAIN,
|
||||
KEY_API_LOCK,
|
||||
KEY_COORDINATOR,
|
||||
KEY_GATEWAY,
|
||||
KEY_MULTICAST_LISTENER,
|
||||
KEY_SETUP_LOCK,
|
||||
KEY_UNSUB_STOP,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import DataUpdateCoordinatorMotionBlinds
|
||||
from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry
|
||||
from .gateway import ConnectMotionGateway
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: MotionBlindsConfigEntry
|
||||
) -> bool:
|
||||
"""Set up the motion_blinds components from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
setup_lock = hass.data[DOMAIN].setdefault(KEY_SETUP_LOCK, asyncio.Lock())
|
||||
host = entry.data[CONF_HOST]
|
||||
key = entry.data[CONF_API_KEY]
|
||||
multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE)
|
||||
wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH)
|
||||
blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST)
|
||||
|
||||
# Create multicast Listener
|
||||
@@ -88,15 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
):
|
||||
raise ConfigEntryNotReady
|
||||
motion_gateway = connect_gateway_class.gateway_device
|
||||
api_lock = asyncio.Lock()
|
||||
coordinator_info = {
|
||||
KEY_GATEWAY: motion_gateway,
|
||||
KEY_API_LOCK: api_lock,
|
||||
CONF_WAIT_FOR_PUSH: wait_for_push,
|
||||
}
|
||||
|
||||
coordinator = DataUpdateCoordinatorMotionBlinds(
|
||||
hass, entry, _LOGGER, coordinator_info
|
||||
hass, entry, _LOGGER, motion_gateway
|
||||
)
|
||||
|
||||
# store blind type list for next time
|
||||
@@ -110,20 +98,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
KEY_GATEWAY: motion_gateway,
|
||||
KEY_COORDINATOR: coordinator,
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert entry.unique_id is not None
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: MotionBlindsConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
@@ -132,7 +116,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if unload_ok:
|
||||
multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER]
|
||||
multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST])
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# No motion gateways left, stop Motion multicast
|
||||
|
||||
@@ -5,25 +5,23 @@ from __future__ import annotations
|
||||
from motionblinds.motion_blinds import LimitStatus, MotionBlind
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
|
||||
from .coordinator import DataUpdateCoordinatorMotionBlinds
|
||||
from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry
|
||||
from .entity import MotionCoordinatorEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Perform the setup for Motionblinds."""
|
||||
entities: list[ButtonEntity] = []
|
||||
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
|
||||
coordinator = config_entry.runtime_data
|
||||
motion_gateway = coordinator.gateway
|
||||
|
||||
for blind in motion_gateway.device_list.values():
|
||||
if blind.limit_status in (
|
||||
|
||||
@@ -9,7 +9,6 @@ from motionblinds import MotionDiscovery, MotionGateway
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
@@ -27,6 +26,7 @@ from .const import (
|
||||
DEFAULT_WAIT_FOR_PUSH,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import MotionBlindsConfigEntry
|
||||
from .gateway import ConnectMotionGateway
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -79,7 +79,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Get the options flow."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
@@ -16,7 +16,6 @@ DEFAULT_INTERFACE = "any"
|
||||
|
||||
KEY_GATEWAY = "gateway"
|
||||
KEY_API_LOCK = "api_lock"
|
||||
KEY_COORDINATOR = "coordinator"
|
||||
KEY_MULTICAST_LISTENER = "multicast_listener"
|
||||
KEY_SETUP_LOCK = "setup_lock"
|
||||
KEY_UNSUB_STOP = "unsub_stop"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""DataUpdateCoordinator for Motionblinds integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from motionblinds import DEVICE_TYPES_WIFI, ParseException
|
||||
from motionblinds import DEVICE_TYPES_WIFI, MotionGateway, ParseException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -14,7 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from .const import (
|
||||
ATTR_AVAILABLE,
|
||||
CONF_WAIT_FOR_PUSH,
|
||||
KEY_API_LOCK,
|
||||
DEFAULT_WAIT_FOR_PUSH,
|
||||
KEY_GATEWAY,
|
||||
UPDATE_INTERVAL,
|
||||
UPDATE_INTERVAL_FAST,
|
||||
@@ -23,17 +24,20 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type MotionBlindsConfigEntry = ConfigEntry[DataUpdateCoordinatorMotionBlinds]
|
||||
|
||||
|
||||
class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
|
||||
"""Class to manage fetching data from single endpoint."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MotionBlindsConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
logger: logging.Logger,
|
||||
coordinator_info: dict[str, Any],
|
||||
gateway: MotionGateway,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
@@ -44,14 +48,16 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
)
|
||||
|
||||
self.api_lock = coordinator_info[KEY_API_LOCK]
|
||||
self._gateway = coordinator_info[KEY_GATEWAY]
|
||||
self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH]
|
||||
self.api_lock = asyncio.Lock()
|
||||
self.gateway = gateway
|
||||
self._wait_for_push = config_entry.options.get(
|
||||
CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH
|
||||
)
|
||||
|
||||
def update_gateway(self):
|
||||
"""Fetch data from gateway."""
|
||||
try:
|
||||
self._gateway.Update()
|
||||
self.gateway.Update()
|
||||
except TimeoutError, ParseException:
|
||||
# let the error be logged and handled by the motionblinds library
|
||||
return {ATTR_AVAILABLE: False}
|
||||
@@ -82,7 +88,7 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
|
||||
self.update_gateway
|
||||
)
|
||||
|
||||
for blind in self._gateway.device_list.values():
|
||||
for blind in self.gateway.device_list.values():
|
||||
await asyncio.sleep(1.5)
|
||||
async with self.api_lock:
|
||||
data[blind.mac] = await self.hass.async_add_executor_job(
|
||||
|
||||
@@ -15,7 +15,6 @@ from homeassistant.components.cover import (
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -25,12 +24,11 @@ from .const import (
|
||||
ATTR_ABSOLUTE_POSITION,
|
||||
ATTR_AVAILABLE,
|
||||
ATTR_WIDTH,
|
||||
DOMAIN,
|
||||
KEY_COORDINATOR,
|
||||
KEY_GATEWAY,
|
||||
SERVICE_SET_ABSOLUTE_POSITION,
|
||||
UPDATE_DELAY_STOP,
|
||||
)
|
||||
from .coordinator import MotionBlindsConfigEntry
|
||||
from .entity import MotionCoordinatorEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -84,13 +82,13 @@ SET_ABSOLUTE_POSITION_SCHEMA: VolDictType = {
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Motion Blind from a config entry."""
|
||||
entities: list[MotionBaseDevice] = []
|
||||
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
|
||||
coordinator = config_entry.runtime_data
|
||||
motion_gateway = coordinator.gateway
|
||||
|
||||
for blind in motion_gateway.device_list.values():
|
||||
if blind.type in POSITION_DEVICE_MAP:
|
||||
|
||||
@@ -10,7 +10,6 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
@@ -19,7 +18,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
|
||||
from .coordinator import MotionBlindsConfigEntry
|
||||
from .entity import MotionCoordinatorEntity
|
||||
|
||||
ATTR_BATTERY_VOLTAGE = "battery_voltage"
|
||||
@@ -27,13 +26,13 @@ ATTR_BATTERY_VOLTAGE = "battery_voltage"
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Perform the setup for Motionblinds."""
|
||||
entities: list[SensorEntity] = []
|
||||
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
|
||||
coordinator = config_entry.runtime_data
|
||||
motion_gateway = coordinator.gateway
|
||||
|
||||
for blind in motion_gateway.device_list.values():
|
||||
entities.append(MotionSignalStrengthSensor(coordinator, blind))
|
||||
|
||||
@@ -45,7 +45,7 @@ from homeassistant.components.webhook import (
|
||||
async_register as webhook_register,
|
||||
async_unregister as webhook_unregister,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, CONF_URL, CONF_WEBHOOK_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
@@ -80,7 +80,7 @@ from .const import (
|
||||
WEB_HOOK_SENTINEL_KEY,
|
||||
WEB_HOOK_SENTINEL_VALUE,
|
||||
)
|
||||
from .coordinator import MotionEyeUpdateCoordinator
|
||||
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
|
||||
@@ -134,7 +134,7 @@ def is_acceptable_camera(camera: dict[str, Any] | None) -> bool:
|
||||
@callback
|
||||
def listen_for_new_cameras(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MotionEyeConfigEntry,
|
||||
add_func: Callable,
|
||||
) -> None:
|
||||
"""Listen for new cameras."""
|
||||
@@ -168,7 +168,7 @@ def _add_camera(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
client: MotionEyeClient,
|
||||
entry: ConfigEntry,
|
||||
entry: MotionEyeConfigEntry,
|
||||
camera_id: int,
|
||||
camera: dict[str, Any],
|
||||
device_identifier: tuple[str, str],
|
||||
@@ -274,9 +274,8 @@ def _add_camera(
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool:
|
||||
"""Set up motionEye from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
client = create_motioneye_client(
|
||||
entry.data[CONF_URL],
|
||||
@@ -306,7 +305,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
coordinator = MotionEyeUpdateCoordinator(hass, entry, client)
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
current_cameras: set[tuple[str, str]] = set()
|
||||
device_registry = dr.async_get(hass)
|
||||
@@ -362,14 +361,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
coordinator = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
await coordinator.client.async_client_close()
|
||||
await entry.runtime_data.client.async_client_close()
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -438,10 +436,14 @@ def _get_media_event_data(
|
||||
event_file_type: int,
|
||||
) -> dict[str, str]:
|
||||
config_entry_id = next(iter(device.config_entries), None)
|
||||
if not config_entry_id or config_entry_id not in hass.data[DOMAIN]:
|
||||
if (
|
||||
not config_entry_id
|
||||
or not (entry := hass.config_entries.async_get_entry(config_entry_id))
|
||||
or entry.state != ConfigEntryState.LOADED
|
||||
):
|
||||
return {}
|
||||
|
||||
coordinator = hass.data[DOMAIN][config_entry_id]
|
||||
coordinator: MotionEyeUpdateCoordinator = entry.runtime_data
|
||||
client = coordinator.client
|
||||
|
||||
for identifier in device.identifiers:
|
||||
|
||||
@@ -30,7 +30,6 @@ from homeassistant.components.mjpeg import (
|
||||
CONF_STILL_IMAGE_URL,
|
||||
MjpegCamera,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_AUTHENTICATION,
|
||||
CONF_NAME,
|
||||
@@ -50,14 +49,13 @@ from .const import (
|
||||
CONF_STREAM_URL_TEMPLATE,
|
||||
CONF_SURVEILLANCE_PASSWORD,
|
||||
CONF_SURVEILLANCE_USERNAME,
|
||||
DOMAIN,
|
||||
MOTIONEYE_MANUFACTURER,
|
||||
SERVICE_ACTION,
|
||||
SERVICE_SET_TEXT_OVERLAY,
|
||||
SERVICE_SNAPSHOT,
|
||||
TYPE_MOTIONEYE_MJPEG_CAMERA,
|
||||
)
|
||||
from .coordinator import MotionEyeUpdateCoordinator
|
||||
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
|
||||
from .entity import MotionEyeEntity
|
||||
|
||||
PLATFORMS = [Platform.CAMERA]
|
||||
@@ -92,11 +90,11 @@ SCHEMA_SERVICE_SET_TEXT = vol.Schema(
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MotionEyeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up motionEye from a config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
@callback
|
||||
def camera_add(camera: dict[str, Any]) -> None:
|
||||
|
||||
@@ -14,7 +14,6 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
@@ -39,6 +38,7 @@ from .const import (
|
||||
DEFAULT_WEBHOOK_SET_OVERWRITE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import MotionEyeConfigEntry
|
||||
|
||||
|
||||
class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -180,7 +180,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionEyeConfigEntry,
|
||||
) -> MotionEyeOptionsFlow:
|
||||
"""Get the Hyperion Options flow."""
|
||||
return MotionEyeOptionsFlow()
|
||||
|
||||
@@ -16,13 +16,16 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type MotionEyeConfigEntry = ConfigEntry[MotionEyeUpdateCoordinator]
|
||||
|
||||
|
||||
class MotionEyeUpdateCoordinator(DataUpdateCoordinator[dict[str, Any] | None]):
|
||||
"""Coordinator for motionEye data."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MotionEyeConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry, client: MotionEyeClient
|
||||
self, hass: HomeAssistant, entry: MotionEyeConfigEntry, client: MotionEyeClient
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
|
||||
@@ -17,12 +17,13 @@ from homeassistant.components.media_source import (
|
||||
PlayMedia,
|
||||
Unresolvable,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import get_media_url, split_motioneye_device_identifier
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MotionEyeConfigEntry
|
||||
|
||||
MIME_TYPE_MAP = {
|
||||
"movies": "video/mp4",
|
||||
@@ -74,7 +75,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
self._verify_kind_or_raise(kind)
|
||||
|
||||
url = get_media_url(
|
||||
self.hass.data[DOMAIN][config.entry_id].client,
|
||||
config.runtime_data.client,
|
||||
self._get_camera_id_or_raise(config, device),
|
||||
self._get_path_or_raise(path),
|
||||
kind == "images",
|
||||
@@ -120,10 +121,10 @@ class MotionEyeMediaSource(MediaSource):
|
||||
return self._build_media_devices(config)
|
||||
return self._build_media_configs()
|
||||
|
||||
def _get_config_or_raise(self, config_id: str) -> ConfigEntry:
|
||||
def _get_config_or_raise(self, config_id: str) -> MotionEyeConfigEntry:
|
||||
"""Get a config entry from a URL."""
|
||||
entry = self.hass.config_entries.async_get_entry(config_id)
|
||||
if not entry:
|
||||
if not entry or entry.state != ConfigEntryState.LOADED:
|
||||
raise MediaSourceError(f"Unable to find config entry with id: {config_id}")
|
||||
return entry
|
||||
|
||||
@@ -154,7 +155,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
|
||||
@classmethod
|
||||
def _get_camera_id_or_raise(
|
||||
cls, config: ConfigEntry, device: dr.DeviceEntry
|
||||
cls, config: MotionEyeConfigEntry, device: dr.DeviceEntry
|
||||
) -> int:
|
||||
"""Get a config entry from a URL."""
|
||||
for identifier in device.identifiers:
|
||||
@@ -164,7 +165,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
raise MediaSourceError(f"Could not find camera id for device id: {device.id}")
|
||||
|
||||
@classmethod
|
||||
def _build_media_config(cls, config: ConfigEntry) -> BrowseMediaSource:
|
||||
def _build_media_config(cls, config: MotionEyeConfigEntry) -> BrowseMediaSource:
|
||||
return BrowseMediaSource(
|
||||
domain=DOMAIN,
|
||||
identifier=config.entry_id,
|
||||
@@ -196,7 +197,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
@classmethod
|
||||
def _build_media_device(
|
||||
cls,
|
||||
config: ConfigEntry,
|
||||
config: MotionEyeConfigEntry,
|
||||
device: dr.DeviceEntry,
|
||||
full_title: bool = True,
|
||||
) -> BrowseMediaSource:
|
||||
@@ -211,7 +212,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
children_media_class=MediaClass.DIRECTORY,
|
||||
)
|
||||
|
||||
def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource:
|
||||
def _build_media_devices(self, config: MotionEyeConfigEntry) -> BrowseMediaSource:
|
||||
"""Build the media sources for device entries."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
devices = dr.async_entries_for_config_entry(device_registry, config.entry_id)
|
||||
@@ -226,7 +227,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
@classmethod
|
||||
def _build_media_kind(
|
||||
cls,
|
||||
config: ConfigEntry,
|
||||
config: MotionEyeConfigEntry,
|
||||
device: dr.DeviceEntry,
|
||||
kind: str,
|
||||
full_title: bool = True,
|
||||
@@ -251,7 +252,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
)
|
||||
|
||||
def _build_media_kinds(
|
||||
self, config: ConfigEntry, device: dr.DeviceEntry
|
||||
self, config: MotionEyeConfigEntry, device: dr.DeviceEntry
|
||||
) -> BrowseMediaSource:
|
||||
base = self._build_media_device(config, device)
|
||||
base.children = [
|
||||
@@ -262,7 +263,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
|
||||
async def _build_media_path(
|
||||
self,
|
||||
config: ConfigEntry,
|
||||
config: MotionEyeConfigEntry,
|
||||
device: dr.DeviceEntry,
|
||||
kind: str,
|
||||
path: str,
|
||||
@@ -276,7 +277,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
|
||||
base.children = []
|
||||
|
||||
client = self.hass.data[DOMAIN][config.entry_id].client
|
||||
client = config.runtime_data.client
|
||||
camera_id = self._get_camera_id_or_raise(config, device)
|
||||
|
||||
if kind == "movies":
|
||||
@@ -286,7 +287,7 @@ class MotionEyeMediaSource(MediaSource):
|
||||
|
||||
sub_dirs: set[str] = set()
|
||||
parts = parsed_path.parts
|
||||
media_list = resp.get(KEY_MEDIA_LIST, [])
|
||||
media_list = resp.get(KEY_MEDIA_LIST, []) if resp else []
|
||||
|
||||
def get_media_sort_key(media: dict) -> str:
|
||||
"""Get media sort key."""
|
||||
|
||||
@@ -9,24 +9,23 @@ from motioneye_client.client import MotionEyeClient
|
||||
from motioneye_client.const import KEY_ACTIONS
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import get_camera_from_cameras, listen_for_new_cameras
|
||||
from .const import DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR
|
||||
from .coordinator import MotionEyeUpdateCoordinator
|
||||
from .const import TYPE_MOTIONEYE_ACTION_SENSOR
|
||||
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
|
||||
from .entity import MotionEyeEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MotionEyeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up motionEye from a config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
@callback
|
||||
def camera_add(camera: dict[str, Any]) -> None:
|
||||
|
||||
@@ -16,14 +16,13 @@ from motioneye_client.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import get_camera_from_cameras, listen_for_new_cameras
|
||||
from .const import DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE
|
||||
from .coordinator import MotionEyeUpdateCoordinator
|
||||
from .const import TYPE_MOTIONEYE_SWITCH_BASE
|
||||
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
|
||||
from .entity import MotionEyeEntity
|
||||
|
||||
MOTIONEYE_SWITCHES = [
|
||||
@@ -68,11 +67,11 @@ MOTIONEYE_SWITCHES = [
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: MotionEyeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up motionEye from a config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
@callback
|
||||
def camera_add(camera: dict[str, Any]) -> None:
|
||||
|
||||
@@ -311,6 +311,19 @@ def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Plat
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the actions and websocket API for the MQTT component."""
|
||||
|
||||
if config.get(DOMAIN) and not mqtt_config_entry_enabled(hass):
|
||||
issue_registry = ir.async_get(hass)
|
||||
issue_registry.async_get_or_create(
|
||||
DOMAIN,
|
||||
"yaml_setup_without_active_setup",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/mqtt/"
|
||||
"#configuration",
|
||||
translation_key="yaml_setup_without_active_setup",
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_subscribe)
|
||||
websocket_api.async_register_command(hass, websocket_mqtt_info)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ ABBREVIATIONS = {
|
||||
"bri_stat_t": "brightness_state_topic",
|
||||
"bri_tpl": "brightness_template",
|
||||
"bri_val_tpl": "brightness_value_template",
|
||||
"cln_segmnts_cmd_t": "clean_segments_command_topic",
|
||||
"cln_segmnts_cmd_tpl": "clean_segments_command_template",
|
||||
"clr_temp_cmd_tpl": "color_temp_command_template",
|
||||
"clrm_stat_t": "color_mode_state_topic",
|
||||
"clrm_val_tpl": "color_mode_value_template",
|
||||
|
||||
@@ -140,6 +140,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"entity_registry_enabled_default",
|
||||
"extra_state_attributes",
|
||||
"force_update",
|
||||
"group_entities",
|
||||
"icon",
|
||||
"friendly_name",
|
||||
"should_poll",
|
||||
|
||||
@@ -1141,6 +1141,10 @@
|
||||
}
|
||||
},
|
||||
"title": "MQTT device \"{name}\" subentry migration to YAML"
|
||||
},
|
||||
"yaml_setup_without_active_setup": {
|
||||
"description": "Home Assistant detected manually configured MQTT items, but these items cannot be loaded because MQTT is not set up correctly. Make sure the MQTT broker is set up correctly, or remove the MQTT configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.",
|
||||
"title": "MQTT is not set up correctly"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -10,12 +10,13 @@ import voluptuous as vol
|
||||
from homeassistant.components import vacuum
|
||||
from homeassistant.components.vacuum import (
|
||||
ENTITY_ID_FORMAT,
|
||||
Segment,
|
||||
StateVacuumEntity,
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -27,13 +28,14 @@ from . import subscription
|
||||
from .config import MQTT_BASE_SCHEMA
|
||||
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC
|
||||
from .entity import MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import ReceiveMessage
|
||||
from .models import MqttCommandTemplate, ReceiveMessage
|
||||
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
|
||||
from .util import valid_publish_topic
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
FAN_SPEED = "fan_speed"
|
||||
SEGMENTS = "segments"
|
||||
STATE = "state"
|
||||
|
||||
STATE_IDLE = "idle"
|
||||
@@ -52,6 +54,8 @@ POSSIBLE_STATES: dict[str, VacuumActivity] = {
|
||||
STATE_CLEANING: VacuumActivity.CLEANING,
|
||||
}
|
||||
|
||||
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic"
|
||||
CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template"
|
||||
CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES
|
||||
CONF_PAYLOAD_TURN_ON = "payload_turn_on"
|
||||
CONF_PAYLOAD_TURN_OFF = "payload_turn_off"
|
||||
@@ -137,8 +141,22 @@ MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset(
|
||||
MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/"
|
||||
|
||||
|
||||
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
|
||||
def validate_clean_area_config(config: ConfigType) -> ConfigType:
|
||||
"""Validate clean area configuration."""
|
||||
if CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config:
|
||||
return config
|
||||
if not config.get(CONF_UNIQUE_ID):
|
||||
raise vol.Invalid(
|
||||
f"Option `{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` requires `{CONF_UNIQUE_ID}` to be configured"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
@@ -164,7 +182,10 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
|
||||
}
|
||||
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
|
||||
|
||||
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA)
|
||||
PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config)
|
||||
DISCOVERY_SCHEMA = vol.All(
|
||||
_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -191,9 +212,11 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
_attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED
|
||||
|
||||
_segments: list[Segment]
|
||||
_command_topic: str | None
|
||||
_set_fan_speed_topic: str | None
|
||||
_send_command_topic: str | None
|
||||
_clean_segments_command_topic: str | None = None
|
||||
_payloads: dict[str, str | None]
|
||||
|
||||
def __init__(
|
||||
@@ -229,6 +252,14 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
self._attr_supported_features = _strings_to_services(
|
||||
supported_feature_strings, STRING_TO_SERVICE
|
||||
)
|
||||
self._clean_segments_command_topic = config.get(
|
||||
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC
|
||||
)
|
||||
self._clean_segments_command_template = MqttCommandTemplate(
|
||||
config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE),
|
||||
entity=self,
|
||||
).async_render
|
||||
|
||||
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
|
||||
self._command_topic = config.get(CONF_COMMAND_TOPIC)
|
||||
self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC)
|
||||
@@ -262,6 +293,24 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None
|
||||
)
|
||||
del payload[STATE]
|
||||
if (
|
||||
(segments_payload := payload.pop(SEGMENTS, None))
|
||||
and self._clean_segments_command_topic is not None
|
||||
and isinstance(segments_payload, dict)
|
||||
and (
|
||||
segments := [
|
||||
Segment(id=segment_id, name=str(segment_name))
|
||||
for segment_id, segment_name in segments_payload.items()
|
||||
]
|
||||
)
|
||||
):
|
||||
self._segments = segments
|
||||
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
|
||||
if (last_seen := self.last_seen_segments) is not None and {
|
||||
s.id: s for s in last_seen
|
||||
} != {s.id: s for s in self._segments}:
|
||||
self.async_create_segments_issue()
|
||||
|
||||
self._update_state_attributes(payload)
|
||||
|
||||
@callback
|
||||
@@ -277,6 +326,20 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
"""(Re)Subscribe to topics."""
|
||||
subscription.async_subscribe_topics_internal(self.hass, self._sub_state)
|
||||
|
||||
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
|
||||
"""Perform an area clean."""
|
||||
assert self._clean_segments_command_topic is not None
|
||||
await self.async_publish_with_config(
|
||||
self._clean_segments_command_topic,
|
||||
self._clean_segments_command_template(
|
||||
json_dumps(segment_ids), {"value": segment_ids}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_get_segments(self) -> list[Segment]:
|
||||
"""Return the available segments."""
|
||||
return self._segments
|
||||
|
||||
async def _async_publish_command(self, feature: VacuumEntityFeature) -> None:
|
||||
"""Publish a command."""
|
||||
if self._command_topic is None:
|
||||
|
||||
@@ -24,12 +24,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import api
|
||||
from .const import DOMAIN, NEATO_LOGIN
|
||||
from .const import DOMAIN
|
||||
from .hub import NeatoHub
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type NeatoConfigEntry = ConfigEntry[NeatoHub]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
@@ -46,9 +48,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool:
|
||||
"""Set up config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
if CONF_TOKEN not in entry.data:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
@@ -69,7 +70,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
neato_session = api.ConfigEntryAuth(hass, entry, implementation)
|
||||
hass.data[DOMAIN][entry.entry_id] = neato_session
|
||||
hub = NeatoHub(hass, Account(neato_session))
|
||||
|
||||
await hub.async_update_entry_unique_id(entry)
|
||||
@@ -80,17 +80,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
_LOGGER.debug("Failed to connect to Neato API")
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
hass.data[NEATO_LOGIN] = hub
|
||||
entry.runtime_data = hub
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool:
|
||||
"""Unload config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -5,22 +5,21 @@ from __future__ import annotations
|
||||
from pybotvac import Robot
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import NEATO_ROBOTS
|
||||
from . import NeatoConfigEntry
|
||||
from .entity import NeatoEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: NeatoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Neato button from config entry."""
|
||||
entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]]
|
||||
entities = [NeatoDismissAlertButton(robot) for robot in entry.runtime_data.robots]
|
||||
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ from pybotvac.robot import Robot
|
||||
from urllib3.response import HTTPResponse
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
|
||||
from . import NeatoConfigEntry
|
||||
from .const import SCAN_INTERVAL_MINUTES
|
||||
from .entity import NeatoEntity
|
||||
from .hub import NeatoHub
|
||||
|
||||
@@ -27,15 +27,14 @@ ATTR_GENERATED_AT = "generated_at"
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: NeatoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Neato camera with config entry."""
|
||||
neato: NeatoHub = hass.data[NEATO_LOGIN]
|
||||
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
|
||||
hub = entry.runtime_data
|
||||
dev = [
|
||||
NeatoCleaningMap(neato, robot, mapdata)
|
||||
for robot in hass.data[NEATO_ROBOTS]
|
||||
NeatoCleaningMap(hub, robot, hub.map_data)
|
||||
for robot in hub.robots
|
||||
if "maps" in robot.traits
|
||||
]
|
||||
|
||||
@@ -51,9 +50,7 @@ class NeatoCleaningMap(NeatoEntity, Camera):
|
||||
|
||||
_attr_translation_key = "cleaning_map"
|
||||
|
||||
def __init__(
|
||||
self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None
|
||||
) -> None:
|
||||
def __init__(self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any]) -> None:
|
||||
"""Initialize Neato cleaning map."""
|
||||
super().__init__(robot)
|
||||
Camera.__init__(self)
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
DOMAIN = "neato"
|
||||
|
||||
CONF_VENDOR = "vendor"
|
||||
NEATO_LOGIN = "neato_login"
|
||||
NEATO_MAP_DATA = "neato_map_data"
|
||||
NEATO_PERSISTENT_MAPS = "neato_persistent_maps"
|
||||
NEATO_ROBOTS = "neato_robots"
|
||||
|
||||
SCAN_INTERVAL_MINUTES = 1
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Support for Neato botvac connected vacuum cleaners."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pybotvac import Account
|
||||
from urllib3.response import HTTPResponse
|
||||
@@ -10,8 +13,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -22,14 +23,17 @@ class NeatoHub:
|
||||
"""Initialize the Neato hub."""
|
||||
self._hass = hass
|
||||
self.my_neato: Account = neato
|
||||
self.robots: set[Any] = set()
|
||||
self.persistent_maps: dict[str, Any] = {}
|
||||
self.map_data: dict[str, Any] = {}
|
||||
|
||||
@Throttle(timedelta(minutes=1))
|
||||
def update_robots(self) -> None:
|
||||
"""Update the robot states."""
|
||||
_LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS))
|
||||
self._hass.data[NEATO_ROBOTS] = self.my_neato.robots
|
||||
self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps
|
||||
self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps
|
||||
_LOGGER.debug("Running HUB.update_robots %s", self.robots)
|
||||
self.robots = self.my_neato.robots
|
||||
self.persistent_maps = self.my_neato.persistent_maps
|
||||
self.map_data = self.my_neato.maps
|
||||
|
||||
def download_map(self, url: str) -> HTTPResponse:
|
||||
"""Download a new map image."""
|
||||
|
||||
@@ -10,12 +10,12 @@ from pybotvac.exceptions import NeatoRobotException
|
||||
from pybotvac.robot import Robot
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
|
||||
from . import NeatoConfigEntry
|
||||
from .const import SCAN_INTERVAL_MINUTES
|
||||
from .entity import NeatoEntity
|
||||
from .hub import NeatoHub
|
||||
|
||||
@@ -28,12 +28,12 @@ BATTERY = "Battery"
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: NeatoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Neato sensor using config entry."""
|
||||
neato: NeatoHub = hass.data[NEATO_LOGIN]
|
||||
dev = [NeatoSensor(neato, robot) for robot in hass.data[NEATO_ROBOTS]]
|
||||
hub = entry.runtime_data
|
||||
dev = [NeatoSensor(hub, robot) for robot in hub.robots]
|
||||
|
||||
if not dev:
|
||||
return
|
||||
|
||||
@@ -10,12 +10,12 @@ from pybotvac.exceptions import NeatoRobotException
|
||||
from pybotvac.robot import Robot
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
|
||||
from . import NeatoConfigEntry
|
||||
from .const import SCAN_INTERVAL_MINUTES
|
||||
from .entity import NeatoEntity
|
||||
from .hub import NeatoHub
|
||||
|
||||
@@ -30,14 +30,14 @@ SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]}
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: NeatoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Neato switch with config entry."""
|
||||
neato: NeatoHub = hass.data[NEATO_LOGIN]
|
||||
hub = entry.runtime_data
|
||||
dev = [
|
||||
NeatoConnectedSwitch(neato, robot, type_name)
|
||||
for robot in hass.data[NEATO_ROBOTS]
|
||||
NeatoConnectedSwitch(hub, robot, type_name)
|
||||
for robot in hub.robots
|
||||
for type_name in SWITCH_TYPES
|
||||
]
|
||||
|
||||
|
||||
@@ -15,22 +15,12 @@ from homeassistant.components.vacuum import (
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
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 .const import (
|
||||
ACTION,
|
||||
ALERTS,
|
||||
ERRORS,
|
||||
MODE,
|
||||
NEATO_LOGIN,
|
||||
NEATO_MAP_DATA,
|
||||
NEATO_PERSISTENT_MAPS,
|
||||
NEATO_ROBOTS,
|
||||
SCAN_INTERVAL_MINUTES,
|
||||
)
|
||||
from . import NeatoConfigEntry
|
||||
from .const import ACTION, ALERTS, ERRORS, MODE, SCAN_INTERVAL_MINUTES
|
||||
from .entity import NeatoEntity
|
||||
from .hub import NeatoHub
|
||||
|
||||
@@ -52,16 +42,16 @@ ATTR_LAUNCHED_FROM = "launched_from"
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: NeatoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Neato vacuum with config entry."""
|
||||
neato: NeatoHub = hass.data[NEATO_LOGIN]
|
||||
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
|
||||
persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS)
|
||||
hub = entry.runtime_data
|
||||
dev = [
|
||||
NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps)
|
||||
for robot in hass.data[NEATO_ROBOTS]
|
||||
NeatoConnectedVacuum(
|
||||
hub, robot, hub.map_data or None, hub.persistent_maps or None
|
||||
)
|
||||
for robot in hub.robots
|
||||
]
|
||||
|
||||
if not dev:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""The NFAndroidTV integration."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -22,8 +22,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up NFAndroidTV from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = entry.data[CONF_HOST]
|
||||
|
||||
hass.async_create_task(
|
||||
discovery.async_load_platform(
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiontfy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiontfy==0.8.3"]
|
||||
"requirements": ["aiontfy==0.8.4"]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The ohmconnect component."""
|
||||
"""The OhmConnect integration."""
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import LOGGER
|
||||
from .const import CONF_WEB_SEARCH, LOGGER
|
||||
|
||||
PLATFORMS = [Platform.AI_TASK, Platform.CONVERSATION]
|
||||
|
||||
@@ -56,3 +56,32 @@ async def _async_update_listener(
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool:
|
||||
"""Unload OpenRouter."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: OpenRouterConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate config entry."""
|
||||
LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 1 or (entry.version == 1 and entry.minor_version > 2):
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
for subentry in entry.subentries.values():
|
||||
if CONF_WEB_SEARCH in subentry.data:
|
||||
continue
|
||||
|
||||
updated_data = {**subentry.data, CONF_WEB_SEARCH: False}
|
||||
|
||||
hass.config_entries.async_update_subentry(
|
||||
entry, subentry, data=updated_data
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.info(
|
||||
"Migration to version %s.%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -27,6 +27,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
@@ -34,7 +35,12 @@ from homeassistant.helpers.selector import (
|
||||
TemplateSelector,
|
||||
)
|
||||
|
||||
from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
|
||||
from .const import (
|
||||
CONF_PROMPT,
|
||||
CONF_WEB_SEARCH,
|
||||
DOMAIN,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,6 +49,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OpenRouter."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
@@ -66,7 +73,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_API_KEY], async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
await client.get_key_data()
|
||||
key_data = await client.get_key_data()
|
||||
except OpenRouterError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
@@ -74,7 +81,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="OpenRouter",
|
||||
title=key_data.label,
|
||||
data=user_input,
|
||||
)
|
||||
return self.async_show_form(
|
||||
@@ -106,7 +113,7 @@ class OpenRouterSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
|
||||
class ConversationFlowHandler(OpenRouterSubentryFlowHandler):
|
||||
"""Handle subentry flow."""
|
||||
"""Handle conversation subentry flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
@@ -208,13 +215,20 @@ class ConversationFlowHandler(OpenRouterSubentryFlowHandler):
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH,
|
||||
default=self.options.get(
|
||||
CONF_WEB_SEARCH,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS[CONF_WEB_SEARCH],
|
||||
),
|
||||
): BooleanSelector(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler):
|
||||
"""Handle subentry flow."""
|
||||
"""Handle AI task subentry flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
|
||||
@@ -9,9 +9,13 @@ DOMAIN = "open_router"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_WEB_SEARCH = "web_search"
|
||||
|
||||
RECOMMENDED_WEB_SEARCH = False
|
||||
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
CONF_WEB_SEARCH: RECOMMENDED_WEB_SEARCH,
|
||||
}
|
||||
|
||||
@@ -37,9 +37,8 @@ from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
|
||||
from . import OpenRouterConfigEntry
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import CONF_WEB_SEARCH, DOMAIN, LOGGER
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
@@ -52,7 +51,6 @@ def _adjust_schema(schema: dict[str, Any]) -> None:
|
||||
if "required" not in schema:
|
||||
schema["required"] = []
|
||||
|
||||
# Ensure all properties are required
|
||||
for prop, prop_info in schema["properties"].items():
|
||||
_adjust_schema(prop_info)
|
||||
if prop not in schema["required"]:
|
||||
@@ -233,14 +231,20 @@ class OpenRouterEntity(Entity):
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
|
||||
model = self.model
|
||||
if self.subentry.data.get(CONF_WEB_SEARCH):
|
||||
model = f"{model}:online"
|
||||
|
||||
extra_body: dict[str, Any] = {"require_parameters": True}
|
||||
|
||||
model_args = {
|
||||
"model": self.model,
|
||||
"model": model,
|
||||
"user": chat_log.conversation_id,
|
||||
"extra_headers": {
|
||||
"X-Title": "Home Assistant",
|
||||
"HTTP-Referer": "https://www.home-assistant.io/integrations/open_router",
|
||||
},
|
||||
"extra_body": {"require_parameters": True},
|
||||
"extra_body": extra_body,
|
||||
}
|
||||
|
||||
tools: list[ChatCompletionFunctionToolParam] | None = None
|
||||
@@ -296,6 +300,10 @@ class OpenRouterEntity(Entity):
|
||||
LOGGER.error("Error talking to API: %s", err)
|
||||
raise HomeAssistantError("Error talking to API") from err
|
||||
|
||||
if not result.choices:
|
||||
LOGGER.error("API returned empty choices")
|
||||
raise HomeAssistantError("API returned empty response")
|
||||
|
||||
result_message = result.choices[0].message
|
||||
|
||||
model_args["messages"].extend(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "open_router",
|
||||
"name": "OpenRouter",
|
||||
"after_dependencies": ["assist_pipeline", "intent"],
|
||||
"codeowners": ["@joostlek"],
|
||||
"codeowners": ["@joostlek", "@ab3lson"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["conversation"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/open_router",
|
||||
|
||||
@@ -23,19 +23,18 @@
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure AI task",
|
||||
"user": "Add AI task"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"model": "[%key:component::open_router::config_subentries::conversation::step::init::data::model%]"
|
||||
},
|
||||
"data_description": {
|
||||
"model": "The model to use for the AI task"
|
||||
"model": "[%key:common::generic::model%]"
|
||||
},
|
||||
"description": "Configure the AI task"
|
||||
}
|
||||
@@ -45,22 +44,27 @@
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"entry_not_loaded": "[%key:component::open_router::config_subentries::ai_task_data::abort::entry_not_loaded%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure conversation agent",
|
||||
"user": "Add conversation agent"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"model": "Model",
|
||||
"prompt": "[%key:common::config_flow::data::prompt%]"
|
||||
"model": "[%key:common::generic::model%]",
|
||||
"prompt": "[%key:common::config_flow::data::prompt%]",
|
||||
"web_search": "Enable web search"
|
||||
},
|
||||
"data_description": {
|
||||
"llm_hass_api": "Select which tools the model can use to interact with your devices and entities.",
|
||||
"model": "The model to use for the conversation agent",
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template."
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||
"web_search": "Allow the model to search the web for answers"
|
||||
},
|
||||
"description": "Configure the conversation agent"
|
||||
}
|
||||
|
||||
@@ -346,7 +346,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
id=event.item.id,
|
||||
tool_name="web_search_call",
|
||||
tool_args={
|
||||
"action": event.item.action.to_dict(),
|
||||
"action": event.item.action.to_dict()
|
||||
if event.item.action
|
||||
else None,
|
||||
},
|
||||
external=True,
|
||||
)
|
||||
@@ -360,6 +362,10 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
}
|
||||
last_role = "tool_result"
|
||||
elif isinstance(event.item, ImageGenerationCall):
|
||||
if last_summary_index is not None:
|
||||
yield {"role": "assistant"}
|
||||
last_role = "assistant"
|
||||
last_summary_index = None
|
||||
yield {"native": event.item}
|
||||
last_summary_index = -1 # Trigger new assistant message on next turn
|
||||
elif isinstance(event, ResponseTextDeltaEvent):
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The opple component."""
|
||||
"""The Opple integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The panasonic_bluray component."""
|
||||
"""The Panasonic Blu-Ray Player integration."""
|
||||
|
||||
@@ -39,7 +39,7 @@ def setup_platform(
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Panasonic Blu-ray platform."""
|
||||
"""Set up the Panasonic Blu-ray media player platform."""
|
||||
conf = discovery_info or config
|
||||
|
||||
# Register configured device with Home Assistant.
|
||||
@@ -59,7 +59,7 @@ class PanasonicBluRay(MediaPlayerEntity):
|
||||
)
|
||||
|
||||
def __init__(self, ip, name):
|
||||
"""Initialize the Panasonic Blue-ray device."""
|
||||
"""Initialize the Panasonic Blu-ray device."""
|
||||
self._device = PanasonicBD(ip)
|
||||
self._attr_name = name
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
|
||||
@@ -17,7 +17,7 @@ ac_start:
|
||||
when:
|
||||
example: "2020-05-01T17:45:00"
|
||||
selector:
|
||||
text:
|
||||
datetime:
|
||||
|
||||
ac_cancel:
|
||||
fields:
|
||||
|
||||
@@ -11,6 +11,7 @@ import voluptuous as vol
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
@@ -163,7 +164,16 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
|
||||
if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]):
|
||||
if (
|
||||
reconfigure_entry.state is not ConfigEntryState.LOADED
|
||||
or reconfigure_entry.data != user_input
|
||||
):
|
||||
if not await self.test_connection(
|
||||
user_input[CONF_HOST], user_input[CONF_PORT]
|
||||
):
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
@@ -171,11 +181,8 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
},
|
||||
title=user_input[CONF_HOST],
|
||||
reload_even_if_entry_is_unchanged=False,
|
||||
)
|
||||
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
suggested_values: dict[str, Any] = {
|
||||
**reconfigure_entry.data,
|
||||
**(user_input or {}),
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The sky_hub component."""
|
||||
"""The Sky Hub integration."""
|
||||
|
||||
@@ -73,8 +73,8 @@ class SureBattery(SurePetcareEntity, SensorEntity):
|
||||
try:
|
||||
per_battery_voltage = state["battery"] / 4
|
||||
voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW
|
||||
self._attr_native_value = min(
|
||||
int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100
|
||||
self._attr_native_value = max(
|
||||
0, min(int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100)
|
||||
)
|
||||
except KeyError, TypeError:
|
||||
self._attr_native_value = None
|
||||
|
||||
@@ -34,15 +34,13 @@ class SynologyDSMbuttonDescription(ButtonEntityDescription):
|
||||
BUTTONS: Final = [
|
||||
SynologyDSMbuttonDescription(
|
||||
key="reboot",
|
||||
name="Reboot",
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda syno_api: syno_api.async_reboot,
|
||||
),
|
||||
SynologyDSMbuttonDescription(
|
||||
key="shutdown",
|
||||
name="Shutdown",
|
||||
icon="mdi:power",
|
||||
translation_key="shutdown",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda syno_api: syno_api.async_shutdown,
|
||||
),
|
||||
@@ -63,6 +61,7 @@ class SynologyDSMButton(ButtonEntity):
|
||||
"""Defines a Synology DSM button."""
|
||||
|
||||
entity_description: SynologyDSMbuttonDescription
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -75,7 +74,6 @@ class SynologyDSMButton(ButtonEntity):
|
||||
if TYPE_CHECKING:
|
||||
assert api.network is not None
|
||||
assert api.information is not None
|
||||
self._attr_name = f"{api.network.hostname} {description.name}"
|
||||
self._attr_unique_id = f"{api.information.serial}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, api.information.serial)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user