Compare commits

..

75 Commits

Author SHA1 Message Date
Franck Nijhof
52fb0343e4 Bump version to 2026.2.0b4 2026-02-04 16:14:23 +00:00
Bram Kragten
1050b4580a Update frontend to 20260128.6 (#162214) 2026-02-04 16:10:08 +00:00
Åke Strandberg
344c42172e Add missing codes for Miele coffe systems (#162206) 2026-02-04 16:10:06 +00:00
Michael Hansen
93cc0fd7f1 Bump intents (#162205) 2026-02-04 16:10:05 +00:00
andreimoraru
05fe636b55 Bump yt-dlp to 2026.02.04 (#162204)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-04 16:10:03 +00:00
Marc Mueller
f22467d099 Pin auth0-python to <5.0 (#162203) 2026-02-04 16:10:01 +00:00
TheJulianJES
4bc3899b32 Bump ZHA to 0.0.89 (#162195) 2026-02-04 16:10:00 +00:00
Oliver
fc4d6bf5f1 Bump denonavr to 1.3.1 (#162183) 2026-02-04 16:09:58 +00:00
johanzander
8ed0672a8f Bump growattServer to 1.9.0 (#162179)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 16:09:57 +00:00
Norbert Rittel
282e347a1b Clarify action descriptions in media_player (#162172) 2026-02-04 16:09:55 +00:00
Erik Montnemery
1bfb02b440 Bump python-otbr-api to 2.8.0 (#162167) 2026-02-04 16:09:54 +00:00
Przemko92
71b03bd9ae Bump compit-inext-api to 0.8.0 (#162166) 2026-02-04 16:09:52 +00:00
Przemko92
cbd69822eb Update compit-inext-api to 0.7.0 (#162020) 2026-02-04 16:09:51 +00:00
Denis Shulyaka
db900f4dd2 Anthropic repair deprecated models (#162162)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-04 16:00:14 +00:00
Jonathan Bangert
a707e695bc Bump bleak-esphome to 3.6.0 (#162028) 2026-02-04 16:00:12 +00:00
Liquidmasl
4feceac205 Jellyfin native client controls (#161982) 2026-02-04 16:00:11 +00:00
Petro31
10c20faaca Fix template weather humidity (#161945) 2026-02-04 16:00:09 +00:00
Robert Svensson
abcd512401 Add missing OUI to Axis integration, discovery would abort with unsup… (#161943) 2026-02-04 16:00:07 +00:00
Bram Kragten
fdf8edf474 Bump version to 2026.2.0b3 2026-02-03 18:03:54 +01:00
Bram Kragten
47e1a98bee Update frontend to 20260128.5 (#162156) 2026-02-03 18:03:04 +01:00
Joost Lekkerkerker
2d8572b943 Add Heatit virtual brand (#162155) 2026-02-03 18:03:02 +01:00
Joost Lekkerkerker
660cfdbd50 Add Heiman virtual brand (#162152) 2026-02-03 18:03:00 +01:00
Steven Travers
4208595da6 Modify Analytics text on feature labs (#162151) 2026-02-03 18:02:59 +01:00
Paul Bottein
b6b2d2fc6f Update title and description of YAML dashboard repair (#162138) 2026-02-03 18:02:58 +01:00
victorigualada
6c4c632848 Handle chat log attachments in Cloud integration (#162121) 2026-02-03 18:02:57 +01:00
Shay Levy
78cf62176f Fix Shelly xpercent sensor state_class (#162107) 2026-02-03 18:02:56 +01:00
Denis Shulyaka
df971c7a42 Anthropic: Switch default model to Haiku 4.5 (#162093) 2026-02-03 18:02:55 +01:00
mezz64
1fcabb7f2d Bump pyhik to 0.4.2 (#162092)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-03 18:02:53 +01:00
Åke Strandberg
9fb60c9ea2 Update Senz temperature sensor (#162016) 2026-02-03 18:02:52 +01:00
J. Diego Rodríguez Royo
9c11a4646f Remove coffee machine's hot water sensor's state class at Home Connect (#161246) 2026-02-03 17:58:47 +01:00
jameson_uk
b036a78776 Remove invalid notification sensors for Alexa devices (#160422)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2026-02-03 17:58:45 +01:00
Kamil Breguła
60bb3cb704 Handle missing battery stats in systemmonitor (#158287)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-03 17:58:43 +01:00
Bram Kragten
0e770958ac Bump version to 2026.2.0b2 2026-02-02 19:12:33 +01:00
Bram Kragten
2a54c71b6c Update frontend to 20260128.4 (#162096) 2026-02-02 19:11:59 +01:00
Steven Travers
50463291ab Add learn more data for Analytics in labs (#162094) 2026-02-02 19:11:59 +01:00
Andrea Turri
43cc34042a Fix Miele dishwasher PowerDisk filling level sensor not showing up (#162048) 2026-02-02 19:11:58 +01:00
Jan Bouwhuis
a02244ccda Bump incomfort-client to 0.6.12 (#162037) 2026-02-02 19:11:57 +01:00
Adrián Moreno
a739619121 Bump pymeteoclimatic to 0.1.1 (#162029) 2026-02-02 19:11:56 +01:00
Åke Strandberg
5db97a5f1c Improved error checking during startup of SENZ (#162026) 2026-02-02 19:11:54 +01:00
Josef Zweck
804ba9c9cc Remove file description dependency in onedrive (#162012) 2026-02-02 19:11:53 +01:00
Filip Bårdsnes Tomren
5ecbcea946 Update ical requirement version to 12.1.3 (#162010) 2026-02-02 19:11:52 +01:00
hanwg
11be2b6289 Fix parse_mode for Telegram bot actions (#162006) 2026-02-02 19:11:51 +01:00
cdnninja
eefae0307b Add integration type of hub to vesync (#162004) 2026-02-02 19:11:50 +01:00
Matthias Alphart
d397ee28ea Fix KNX fan unique_id for switch-only fans (#162002) 2026-02-02 19:11:49 +01:00
starkillerOG
02c821128e Bump reolink-aio to 0.18.2 (#161998) 2026-02-02 19:11:48 +01:00
Shay Levy
71dc15d45f Fix Shelly CoIoT repair issue (#161973)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-02 19:11:47 +01:00
Raphael Hehl
1078387b22 Bump uiprotect to version 10.1.0 (#161967)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-02-02 19:11:46 +01:00
tronikos
35fab27d15 Bump opower to 0.17.0 (#161962) 2026-02-02 19:11:45 +01:00
Yuxin Wang
915dc7a908 Mark datetime sensors as unknown when parsing fails (#161952) 2026-02-02 19:11:44 +01:00
mvn23
e5a9738983 Fix OpenTherm Gateway button availability (#161933) 2026-02-02 19:11:43 +01:00
mvn23
2ff73219a2 Bump pyotgw to 2.2.3 (#161928) 2026-02-02 19:11:42 +01:00
epenet
5dc1270ed1 Fix mired warning in template light (#161923) 2026-02-02 19:11:41 +01:00
J. Diego Rodríguez Royo
9e95ad5a85 Restore the Home Connect program option entities (#156401)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-02-02 19:11:40 +01:00
Franck Nijhof
9a5d4610f7 Bump version to 2026.2.0b1 2026-01-30 11:45:08 +00:00
Paul Bottein
41c524fce4 Update frontend to 20260128.3 (#161918) 2026-01-30 11:44:54 +00:00
David Recordon
5f9fa95554 Fix Control4 HVAC state-to-action mapping (#161916) 2026-01-30 11:44:51 +00:00
Simone Chemelli
6950be8ea9 Handle hostname resolution for Shelly repair issue (#161914) 2026-01-30 11:44:47 +00:00
puddly
c5a8bf64d0 Bump ZHA to 0.0.88 (#161904) 2026-01-30 11:44:44 +00:00
hanwg
a2b9a6e9df Update translations for Telegram bot (#161903) 2026-01-30 11:44:43 +00:00
Marc Mueller
a0c567f0da Update fritzconnection to 1.15.1 (#161887) 2026-01-30 11:44:40 +00:00
Bram Kragten
c7feafdde6 Update frontend to 20260128.2 (#161881) 2026-01-30 11:44:38 +00:00
Björn Dalfors
e1e74b0aeb Bump nibe to 2.22.0 (#161873) 2026-01-30 11:44:36 +00:00
Sebastiaan Speck
673411ef97 Bump renault-api to 0.5.3 (#161857) 2026-01-30 11:44:34 +00:00
epenet
f7e5af7cb1 Fix incorrect entity_description class in radarr (#161856) 2026-01-30 11:44:32 +00:00
Norbert Rittel
0ee56ce708 Fix action descriptions of alarm_control_panel (#161852) 2026-01-30 11:44:30 +00:00
Manu
f93a176398 Fix string in Namecheap DynamicDNS integration (#161821) 2026-01-30 11:44:28 +00:00
Paul Bottein
cd2394bc12 Allow lovelace path for dashboard in yaml and fix yaml dashboard migration (#161816) 2026-01-30 11:44:26 +00:00
Michael Hansen
5c20b8eaff Bump intents to 2026.1.28 (#161813) 2026-01-30 11:44:25 +00:00
Aaron Godfrey
4bd499d3a6 Update todoist-api-python to 3.1.0 (#161811)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-30 11:44:23 +00:00
Jan Bouwhuis
8a53b94c5a Fix use of ambiguous units for reactive power and energy (#161810) 2026-01-30 11:44:20 +00:00
victorigualada
d5aff326e3 Use OpenAI schema dataclasses for cloud stream responses (#161663) 2026-01-30 11:44:18 +00:00
Gage Benne
22f66abbe7 Bump pydexcom to 0.5.1 (#161549) 2026-01-30 11:44:16 +00:00
Mattia Monga
f635228b1f Make viaggiatreno work by fixing some critical bugs (#160093)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-30 11:44:14 +00:00
Artur Pragacz
4c708c143d Fix validation of actions config in intent_script (#158266) 2026-01-30 11:44:12 +00:00
Franck Nijhof
3369459d41 Bump version to 2026.2.0b0 2026-01-28 20:00:19 +00:00
565 changed files with 15082 additions and 22945 deletions

View File

@@ -1,118 +0,0 @@
name: "Image builder"
description: "Build a Docker image"
inputs:
base-image:
description: "Base image to use for the build"
required: true
# example: 'ghcr.io/home-assistant/amd64-homeassistant-base:2024.6.0'
tags:
description: "Tag(s) for the built image (can be multiline for multiple tags)"
required: true
# example: 'ghcr.io/home-assistant/amd64-homeassistant:2026.2.0' or multiline for multiple tags
arch:
description: "Architecture for the build (used for default labels)"
required: true
# example: 'amd64'
version:
description: "Version for the build (used for default labels)"
required: true
# example: '2026.2.0'
dockerfile:
description: "Path to the Dockerfile to build"
required: true
# example: './Dockerfile'
cosign-base-identity:
description: "Certificate identity regexp for base image verification"
required: true
# example: 'https://github.com/home-assistant/docker/.*'
additional-labels:
description: "Additional labels to add to the built image (merged with default labels)"
required: false
default: ""
# example: 'custom.label=value'
push:
description: "Whether to push the image to the registry"
required: false
default: "true"
# example: 'true' or 'false'
runs:
using: "composite"
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Verify base image signature
shell: bash
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "${{ inputs.cosign-base-identity }}" \
"${{ inputs.base-image }}"
- name: Verify cache image signature
id: cache
continue-on-error: true
shell: bash
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
"ghcr.io/home-assistant/${{ inputs.arch }}-homeassistant:latest"
- name: Prepare labels
id: labels
shell: bash
run: |
# Generate creation timestamp
CREATED=$(date --rfc-3339=seconds --utc)
# Build default labels array
LABELS=(
"io.hass.arch=${{ inputs.arch }}"
"io.hass.version=${{ inputs.version }}"
"org.opencontainers.image.created=${CREATED}"
"org.opencontainers.image.version=${{ inputs.version }}"
)
# Append additional labels if provided
if [ -n "${{ inputs.additional-labels }}" ]; then
while IFS= read -r label; do
[ -n "$label" ] && LABELS+=("$label")
done <<< "${{ inputs.additional-labels }}"
fi
# Output the combined labels using EOF delimiter for multiline
{
echo 'result<<EOF'
printf '%s\n' "${LABELS[@]}"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Build base image
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ${{ inputs.dockerfile }}
platforms: ${{ steps.vars.outputs.platform }}
push: ${{ inputs.push }}
cache-from: ${{ steps.cache.outcome == 'success' && format('ghcr.io/home-assistant/{0}-homeassistant:latest', inputs.arch) || '' }}
build-args: |
BUILD_FROM=${{ inputs.base-image }}
tags: ${{ inputs.tags }}
outputs: type=image,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
labels: ${{ steps.labels.outputs.result }}
- name: Sign image
if: ${{ inputs.push == 'true' }}
shell: bash
run: |
# Sign each tag
while IFS= read -r tag; do
[ -n "$tag" ] && cosign sign --yes "${tag}@${{ steps.build.outputs.digest }}"
done <<< "${{ inputs.tags }}"

View File

@@ -1,68 +0,0 @@
name: "Machine image builder"
description: "Build or copy a machine-specific Docker image"
inputs:
machine:
description: "Machine name"
required: true
# example: 'raspberrypi4-64'
version:
description: "Version for the build"
required: true
# example: '2026.2.0'
arch:
description: "Architecture for the build"
required: true
# example: 'aarch64'
runs:
using: "composite"
steps:
- name: Prepare build variables
id: vars
shell: bash
run: |
echo "base_image=ghcr.io/home-assistant/${{ inputs.arch }}-homeassistant:${{ inputs.version }}" >> "$GITHUB_OUTPUT"
# Build tags array with version-specific tag
TAGS=(
"ghcr.io/home-assistant/${{ inputs.machine }}-homeassistant:${{ inputs.version }}"
)
# Add general tag based on version
if [[ "${{ inputs.version }}" =~ d ]]; then
TAGS+=("ghcr.io/home-assistant/${{ inputs.machine }}-homeassistant:dev")
elif [[ "${{ inputs.version }}" =~ b ]]; then
TAGS+=("ghcr.io/home-assistant/${{ inputs.machine }}-homeassistant:beta")
else
TAGS+=("ghcr.io/home-assistant/${{ inputs.machine }}-homeassistant:stable")
fi
# Output tags using EOF delimiter for multiline
{
echo 'tags<<EOF'
printf '%s\n' "${TAGS[@]}"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
LABELS=(
"io.hass.type=core"
"io.hass.machine=${{ inputs.machine }}"
"org.opencontainers.image.source=https://github.com/home-assistant/core"
)
# Output the labels using EOF delimiter for multiline
{
echo 'labels<<EOF'
printf '%s\n' "${LABELS[@]}"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Build machine image
uses: ./.github/actions/builder/generic
with:
base-image: ${{ steps.vars.outputs.base_image }}
tags: ${{ steps.vars.outputs.tags }}
arch: ${{ inputs.arch }}
version: ${{ inputs.version }}
dockerfile: machine/${{ inputs.machine }}
cosign-base-identity: "https://github.com/home-assistant/core/.*"
additional-labels: ${{ steps.vars.outputs.labels }}

View File

@@ -10,12 +10,12 @@ on:
env:
BUILD_TYPE: core
DEFAULT_PYTHON: "3.14.2"
DEFAULT_PYTHON: "3.13"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.01.0"
BASE_IMAGE_VERSION: "2025.12.0"
ARCHITECTURES: '["amd64", "aarch64"]'
jobs:
@@ -100,7 +100,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -111,7 +111,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -184,72 +184,124 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- &install_cosign
name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build variables
id: vars
shell: bash
run: |
echo "base_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ env.BASE_IMAGE_VERSION }}" >> "$GITHUB_OUTPUT"
echo "cache_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:latest" >> "$GITHUB_OUTPUT"
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
- name: Verify base image signature
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
"${{ steps.vars.outputs.base_image }}"
- name: Verify cache image signature
id: cache
continue-on-error: true
run: |
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
"${{ steps.vars.outputs.cache_image }}"
- name: Build base image
uses: ./.github/actions/builder/generic
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
base-image: ${{ steps.vars.outputs.base_image }}
context: .
file: ./Dockerfile
platforms: ${{ steps.vars.outputs.platform }}
push: true
cache-from: ${{ steps.cache.outcome == 'success' && steps.vars.outputs.cache_image || '' }}
build-args: |
BUILD_FROM=${{ steps.vars.outputs.base_image }}
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
arch: ${{ matrix.arch }}
version: ${{ needs.init.outputs.version }}
dockerfile: ./Dockerfile
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
labels: |
io.hass.arch=${{ matrix.arch }}
io.hass.version=${{ needs.init.outputs.version }}
org.opencontainers.image.created=${{ steps.vars.outputs.created }}
org.opencontainers.image.version=${{ needs.init.outputs.version }}
- name: Sign image
run: |
cosign sign --yes "ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}@${{ steps.build.outputs.digest }}"
build_machine:
name: Build ${{ matrix.machine.name }} machine core image
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ${{ matrix.machine.arch == 'amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
strategy:
fail-fast: false
matrix:
machine:
- { name: generic-x86-64, arch: amd64 }
- { name: intel-nuc, arch: amd64 }
- { name: qemux86-64, arch: amd64 }
- { name: khadas-vim3, arch: aarch64 }
- { name: odroid-c2, arch: aarch64 }
- { name: odroid-c4, arch: aarch64 }
- { name: odroid-m1, arch: aarch64 }
- { name: odroid-n2, arch: aarch64 }
- { name: qemuarm-64, arch: aarch64 }
- { name: raspberrypi3-64, arch: aarch64 }
- { name: raspberrypi4-64, arch: aarch64 }
- { name: raspberrypi5-64, arch: aarch64 }
- { name: yellow, arch: aarch64 }
- { name: green, arch: aarch64 }
- generic-x86-64
- intel-nuc
- khadas-vim3
- odroid-c2
- odroid-c4
- odroid-m1
- odroid-n2
- qemuarm-64
- qemux86-64
- raspberrypi3-64
- raspberrypi4-64
- raspberrypi5-64
- yellow
- green
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set build additional args
run: |
# Create general tags
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
else
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build machine image
uses: ./.github/actions/builder/machine
# home-assistant/builder doesn't support sha pinning
- name: Build base image
uses: home-assistant/builder@2025.11.0
with:
machine: ${{ matrix.machine.name }}
version: ${{ needs.init.outputs.version }}
arch: ${{ matrix.machine.arch }}
args: |
$BUILD_ARGS \
--target /data/machine \
--cosign \
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
publish_ha:
name: Publish version files
@@ -302,20 +354,17 @@ jobs:
matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
- *install_cosign
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -473,7 +522,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

View File

@@ -40,9 +40,9 @@ env:
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.3"
DEFAULT_PYTHON: "3.14.2"
ALL_PYTHON_VERSIONS: "['3.14.2']"
HA_SHORT_VERSION: "2026.2"
DEFAULT_PYTHON: "3.13.11"
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support
@@ -310,7 +310,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: &actions-cache actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: venv
key: &key-python-venv >-
@@ -374,7 +374,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: &actions-cache-save actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -425,7 +425,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
uses: &actions-cache-restore actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: &actions-cache-restore actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
fail-on-cache-miss: true

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
with:
category: "/language:python"

View File

@@ -10,7 +10,7 @@ on:
- "**strings.json"
env:
DEFAULT_PYTHON: "3.14.2"
DEFAULT_PYTHON: "3.13"
jobs:
upload:

View File

@@ -17,7 +17,7 @@ on:
- "script/gen_requirements_all.py"
env:
DEFAULT_PYTHON: "3.14.2"
DEFAULT_PYTHON: "3.13"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}

View File

@@ -1 +1 @@
3.14
3.13

View File

@@ -389,7 +389,6 @@ homeassistant.components.onkyo.*
homeassistant.components.open_meteo.*
homeassistant.components.open_router.*
homeassistant.components.openai_conversation.*
homeassistant.components.openevse.*
homeassistant.components.openexchangerates.*
homeassistant.components.opensky.*
homeassistant.components.openuv.*

4
CODEOWNERS generated
View File

@@ -921,8 +921,6 @@ build.json @home-assistant/supervisor
/tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob
/homeassistant/components/liebherr/ @mettolen
/tests/components/liebherr/ @mettolen
/homeassistant/components/lifx/ @Djelibeybi
/tests/components/lifx/ @Djelibeybi
/homeassistant/components/light/ @home-assistant/core
@@ -1880,8 +1878,6 @@ build.json @home-assistant/supervisor
/tests/components/worldclock/ @fabaff
/homeassistant/components/ws66i/ @ssaenger
/tests/components/ws66i/ @ssaenger
/homeassistant/components/wsdot/ @ucodery
/tests/components/wsdot/ @ucodery
/homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @hunterjm @tr4nt0r

View File

@@ -52,9 +52,6 @@ RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
uv pip install -r requirements.txt -r requirements_test.txt
# Claude Code native install
RUN curl -fsSL https://claude.ai/install.sh | bash
WORKDIR /workspaces
# Set the default shell to bash instead of sh

View File

@@ -7,12 +7,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, SERVER_URL
from .services import async_setup_services
ATTRIBUTION = "ispyconnect.com"
DEFAULT_BRAND = "Agent DVR by ispyconnect.com"
@@ -21,14 +19,6 @@ PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA]
AgentDVRConfigEntry = ConfigEntry[Agent]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(
hass: HomeAssistant, config_entry: AgentDVRConfigEntry

View File

@@ -9,7 +9,10 @@ from homeassistant.components.camera import CameraEntityFeature
from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from . import AgentDVRConfigEntry
from .const import ATTRIBUTION, CAMERA_SCAN_INTERVAL_SECS, DOMAIN
@@ -18,6 +21,20 @@ SCAN_INTERVAL = timedelta(seconds=CAMERA_SCAN_INTERVAL_SECS)
_LOGGER = logging.getLogger(__name__)
_DEV_EN_ALT = "enable_alerts"
_DEV_DS_ALT = "disable_alerts"
_DEV_EN_REC = "start_recording"
_DEV_DS_REC = "stop_recording"
_DEV_SNAP = "snapshot"
CAMERA_SERVICES = {
_DEV_EN_ALT: "async_enable_alerts",
_DEV_DS_ALT: "async_disable_alerts",
_DEV_EN_REC: "async_start_recording",
_DEV_DS_REC: "async_stop_recording",
_DEV_SNAP: "async_snapshot",
}
async def async_setup_entry(
hass: HomeAssistant,
@@ -40,6 +57,10 @@ async def async_setup_entry(
async_add_entities(cameras)
platform = async_get_current_platform()
for service, method in CAMERA_SERVICES.items():
platform.async_register_entity_service(service, None, method)
class AgentCamera(MjpegCamera):
"""Representation of an Agent Device Stream."""

View File

@@ -1,38 +0,0 @@
"""Services for Agent DVR."""
from __future__ import annotations
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import service
from .const import DOMAIN
_DEV_EN_ALT = "enable_alerts"
_DEV_DS_ALT = "disable_alerts"
_DEV_EN_REC = "start_recording"
_DEV_DS_REC = "stop_recording"
_DEV_SNAP = "snapshot"
CAMERA_SERVICES = {
_DEV_EN_ALT: "async_enable_alerts",
_DEV_DS_ALT: "async_disable_alerts",
_DEV_EN_REC: "async_start_recording",
_DEV_DS_REC: "async_stop_recording",
_DEV_SNAP: "async_snapshot",
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
for service_name, method in CAMERA_SERVICES.items():
service.async_register_platform_entity_service(
hass,
DOMAIN,
service_name,
entity_domain=CAMERA_DOMAIN,
schema=None,
func=method,
)

View File

@@ -18,15 +18,12 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_DEVICE_BAUD,
CONF_DEVICE_PATH,
DOMAIN,
PROTOCOL_SERIAL,
PROTOCOL_SOCKET,
SIGNAL_PANEL_MESSAGE,
@@ -35,11 +32,9 @@ from .const import (
SIGNAL_ZONE_FAULT,
SIGNAL_ZONE_RESTORE,
)
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
@@ -59,12 +54,6 @@ class AlarmDecoderData:
restart: bool
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(
hass: HomeAssistant, entry: AlarmDecoderConfigEntry
) -> bool:

View File

@@ -2,13 +2,17 @@
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -23,6 +27,11 @@ from .const import (
)
from .entity import AlarmDecoderEntity
SERVICE_ALARM_TOGGLE_CHIME = "alarm_toggle_chime"
SERVICE_ALARM_KEYPRESS = "alarm_keypress"
ATTR_KEYPRESS = "keypress"
async def async_setup_entry(
hass: HomeAssistant,
@@ -41,6 +50,23 @@ async def async_setup_entry(
)
async_add_entities([entity])
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_ALARM_TOGGLE_CHIME,
{
vol.Required(ATTR_CODE): cv.string,
},
"alarm_toggle_chime",
)
platform.async_register_entity_service(
SERVICE_ALARM_KEYPRESS,
{
vol.Required(ATTR_KEYPRESS): cv.string,
},
"alarm_keypress",
)
class AlarmDecoderAlarmPanel(AlarmDecoderEntity, AlarmControlPanelEntity):
"""Representation of an AlarmDecoder-based alarm panel."""

View File

@@ -1,46 +0,0 @@
"""Support for AlarmDecoder-based alarm control panels (Honeywell/DSC)."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.alarm_control_panel import (
DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
)
from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
SERVICE_ALARM_TOGGLE_CHIME = "alarm_toggle_chime"
SERVICE_ALARM_KEYPRESS = "alarm_keypress"
ATTR_KEYPRESS = "keypress"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_ALARM_TOGGLE_CHIME,
entity_domain=ALARM_CONTROL_PANEL_DOMAIN,
schema={
vol.Required(ATTR_CODE): cv.string,
},
func="alarm_toggle_chime",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_ALARM_KEYPRESS,
entity_domain=ALARM_CONTROL_PANEL_DOMAIN,
schema={
vol.Required(ATTR_KEYPRESS): cv.string,
},
func="alarm_keypress",
)

View File

@@ -59,15 +59,13 @@ async def async_setup_entry(
coordinator = entry.runtime_data
# DND keys
old_key = "do_not_disturb"
new_key = "dnd"
# Replace unique id for "DND" switch and remove from Speaker Group
await async_update_unique_id(
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
)
# Remove old DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator, old_key)
# Replace unique id for DND switch
await async_update_unique_id(hass, coordinator, SWITCH_DOMAIN, old_key, new_key)
# Remove DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator)
known_devices: set[str] = set()

View File

@@ -54,7 +54,7 @@ def alexa_api_call[_T: AmazonEntity, **_P](
async def async_update_unique_id(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
platform: str,
domain: str,
old_key: str,
new_key: str,
) -> None:
@@ -63,9 +63,7 @@ async def async_update_unique_id(
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{old_key}"
if entity_id := entity_registry.async_get_entity_id(
DOMAIN, platform, unique_id
):
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
_LOGGER.debug("Updating unique_id for %s", entity_id)
new_unique_id = unique_id.replace(old_key, new_key)
@@ -76,13 +74,12 @@ async def async_update_unique_id(
async def async_remove_dnd_from_virtual_group(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
key: str,
) -> None:
"""Remove entity DND from virtual group."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{key}"
unique_id = f"{serial_num}-do_not_disturb"
entity_id = entity_registry.async_get_entity_id(
DOMAIN, SWITCH_DOMAIN, unique_id
)
@@ -107,7 +104,7 @@ async def async_remove_unsupported_notification_sensors(
):
unique_id = f"{serial_num}-{notification_key}"
entity_id = entity_registry.async_get_entity_id(
DOMAIN, SENSOR_DOMAIN, unique_id=unique_id
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
)
is_unsupported = not coordinator.data[serial_num].notifications_supported

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator, Callable
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
@@ -202,7 +202,7 @@ class AmcrestChecker(ApiWrapper):
@asynccontextmanager
async def async_stream_command(
self, *args: Any, **kwargs: Any
) -> AsyncGenerator[httpx.Response]:
) -> AsyncIterator[httpx.Response]:
"""amcrest.ApiWrapper.command wrapper to catch errors."""
async with (
self._async_command_wrapper(),
@@ -211,7 +211,7 @@ class AmcrestChecker(ApiWrapper):
yield ret
@asynccontextmanager
async def _async_command_wrapper(self) -> AsyncGenerator[None]:
async def _async_command_wrapper(self) -> AsyncIterator[None]:
try:
yield
except LoginError as ex:

View File

@@ -600,16 +600,6 @@ class AnthropicBaseLLMEntity(Entity):
system = chat_log.content[0]
if not isinstance(system, conversation.SystemContent):
raise TypeError("First message must be a system message")
# System prompt with caching enabled
system_prompt: list[TextBlockParam] = [
TextBlockParam(
type="text",
text=system.content,
cache_control={"type": "ephemeral"},
)
]
messages = _convert_content(chat_log.content[1:])
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
@@ -618,7 +608,7 @@ class AnthropicBaseLLMEntity(Entity):
model=model,
messages=messages,
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
system=system_prompt,
system=system.content,
stream=True,
)
@@ -705,6 +695,10 @@ class AnthropicBaseLLMEntity(Entity):
type="auto",
)
if isinstance(model_args["system"], str):
model_args["system"] = [
TextBlockParam(type="text", text=model_args["system"])
]
model_args["system"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.16"]
"requirements": ["py-aosmith==1.0.15"]
}

View File

@@ -2,16 +2,15 @@
from __future__ import annotations
from pyatv.const import FeatureName, FeatureState, KeyboardFocusState
from pyatv.const import KeyboardFocusState
from pyatv.interface import AppleTV, KeyboardListener
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SIGNAL_CONNECTED, AppleTvConfigEntry
from . import AppleTvConfigEntry
from .entity import AppleTVEntity
@@ -22,22 +21,10 @@ async def async_setup_entry(
) -> None:
"""Load Apple TV binary sensor based on a config entry."""
# apple_tv config entries always have a unique id
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
manager = config_entry.runtime_data
cb: CALLBACK_TYPE
def setup_entities(atv: AppleTV) -> None:
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
async_add_entities(
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
)
cb()
cb = async_dispatcher_connect(
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
)
config_entry.async_on_unload(cb)
async_add_entities([AppleTVKeyboardFocused(name, config_entry.unique_id, manager)])
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):

View File

@@ -26,7 +26,6 @@ EXCLUDE_FROM_BACKUP = [
"tmp_backups/*.tar",
"OZW_Log.txt",
"tts/*",
".cache/*",
]
EXCLUDE_DATABASE_FROM_BACKUP = [

View File

@@ -1,7 +1,6 @@
"""Support for Baidu speech service."""
import logging
from typing import Any
from aip import AipSpeech
import voluptuous as vol
@@ -10,7 +9,6 @@ from homeassistant.components.tts import (
CONF_LANG,
PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA,
Provider,
TtsAudioType,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import config_validation as cv
@@ -87,17 +85,17 @@ class BaiduTTSProvider(Provider):
}
@property
def default_language(self) -> str:
def default_language(self):
"""Return the default language."""
return self._lang
@property
def supported_languages(self) -> list[str]:
def supported_languages(self):
"""Return a list of supported languages."""
return SUPPORTED_LANGUAGES
@property
def default_options(self) -> dict[str, Any]:
def default_options(self):
"""Return a dict including default options."""
return {
CONF_PERSON: self._speech_conf_data[_OPTIONS[CONF_PERSON]],
@@ -107,16 +105,11 @@ class BaiduTTSProvider(Provider):
}
@property
def supported_options(self) -> list[str]:
def supported_options(self):
"""Return a list of supported options."""
return SUPPORTED_OPTIONS
def get_tts_audio(
self,
message: str,
language: str,
options: dict[str, Any],
) -> TtsAudioType:
def get_tts_audio(self, message, language, options):
"""Load TTS from BaiduTTS."""
aip_speech = AipSpeech(

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/bang_olufsen",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["mozart-api==5.3.1.108.2"],
"requirements": ["mozart-api==5.3.1.108.0"],
"zeroconf": ["_bangolufsen._tcp.local."]
}

View File

@@ -8,7 +8,6 @@ from datetime import timedelta
import json
import logging
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from aiohttp import ClientConnectorError
from mozart_api import __version__ as MOZART_API_VERSION
@@ -736,7 +735,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
await self._client.set_active_source(source_id=key)
else:
# Video
await self._client.post_remote_trigger(id=UUID(key))
await self._client.post_remote_trigger(id=key)
async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Select a sound mode."""
@@ -895,7 +894,7 @@ class BeoMediaPlayer(BeoEntity, MediaPlayerEntity):
translation_key="play_media_error",
translation_placeholders={
"media_type": media_type,
"error_message": json.loads(cast(str, error.body))["message"],
"error_message": json.loads(error.body)["message"],
},
) from error

View File

@@ -6,9 +6,16 @@ from typing import Any
from blinkpy.auth import Auth
from blinkpy.blinkpy import Blink
import voluptuous as vol
from homeassistant.components import persistent_notification
from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.const import (
CONF_FILE_PATH,
CONF_FILENAME,
CONF_NAME,
CONF_PIN,
CONF_SCAN_INTERVAL,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -20,6 +27,13 @@ from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema(
{vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILENAME): cv.string}
)
SERVICE_SEND_PIN_SCHEMA = vol.Schema({vol.Optional(CONF_PIN): cv.string})
SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema(
{vol.Required(CONF_NAME): cv.string, vol.Required(CONF_FILE_PATH): cv.string}
)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)

View File

@@ -9,23 +9,35 @@ from typing import Any
from blinkpy.auth import UnauthorizedError
from blinkpy.camera import BlinkCamera as BlinkCameraAPI
from requests.exceptions import ChunkedEncodingError
import voluptuous as vol
from homeassistant.components.camera import Camera
from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_BRAND, DOMAIN
from .const import (
DEFAULT_BRAND,
DOMAIN,
SERVICE_RECORD,
SERVICE_SAVE_RECENT_CLIPS,
SERVICE_SAVE_VIDEO,
SERVICE_TRIGGER,
)
from .coordinator import BlinkConfigEntry, BlinkUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
ATTR_VIDEO_CLIP = "video"
ATTR_IMAGE = "image"
PARALLEL_UPDATES = 1
@@ -44,6 +56,20 @@ async def async_setup_entry(
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(SERVICE_RECORD, None, "record")
platform.async_register_entity_service(SERVICE_TRIGGER, None, "trigger_camera")
platform.async_register_entity_service(
SERVICE_SAVE_RECENT_CLIPS,
{vol.Required(CONF_FILE_PATH): cv.string},
"save_recent_clips",
)
platform.async_register_entity_service(
SERVICE_SAVE_VIDEO,
{vol.Required(CONF_FILENAME): cv.string},
"save_video",
)
class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
"""An implementation of a Blink Camera."""

View File

@@ -20,6 +20,11 @@ TYPE_TEMPERATURE = "temperature"
TYPE_BATTERY = "battery"
TYPE_WIFI_STRENGTH = "wifi_strength"
SERVICE_RECORD = "record"
SERVICE_TRIGGER = "trigger_camera"
SERVICE_SAVE_VIDEO = "save_video"
SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips"
SERVICE_SEND_PIN = "send_pin"
PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,

View File

@@ -4,27 +4,13 @@ from __future__ import annotations
import voluptuous as vol
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.const import (
ATTR_CONFIG_ENTRY_ID,
CONF_FILE_PATH,
CONF_FILENAME,
CONF_PIN,
)
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir, service
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from .const import DOMAIN
from .const import DOMAIN, SERVICE_SEND_PIN
SERVICE_RECORD = "record"
SERVICE_TRIGGER = "trigger_camera"
SERVICE_SAVE_VIDEO = "save_video"
SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips"
# Deprecated
SERVICE_SEND_PIN = "send_pin"
SERVICE_SEND_PIN_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]),
@@ -66,36 +52,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
_send_pin,
schema=SERVICE_SEND_PIN_SCHEMA,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_RECORD,
entity_domain=CAMERA_DOMAIN,
schema=None,
func="record",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_TRIGGER,
entity_domain=CAMERA_DOMAIN,
schema=None,
func="trigger_camera",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SAVE_RECENT_CLIPS,
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(CONF_FILE_PATH): cv.string},
func="save_recent_clips",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SAVE_VIDEO,
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(CONF_FILENAME): cv.string},
func="save_video",
)

View File

@@ -16,17 +16,14 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import SLOW_UPDATE_WARNING
from homeassistant.helpers.typing import ConfigType
from .const import BRIDGE_MAKE, DOMAIN
from .models import BondData
from .services import async_setup_services
from .utils import BondHub
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BUTTON,
Platform.COVER,
@@ -41,12 +38,6 @@ _LOGGER = logging.getLogger(__name__)
type BondConfigEntry = ConfigEntry[BondData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BondConfigEntry) -> bool:
"""Set up Bond from a config entry."""
host = entry.data[CONF_HOST]

View File

@@ -5,3 +5,10 @@ BRIDGE_MAKE = "Olibra"
DOMAIN = "bond"
CONF_BOND_ID: str = "bond_id"
SERVICE_SET_FAN_SPEED_TRACKED_STATE = "set_fan_speed_tracked_state"
SERVICE_SET_POWER_TRACKED_STATE = "set_switch_power_tracked_state"
SERVICE_SET_LIGHT_POWER_TRACKED_STATE = "set_light_power_tracked_state"
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE = "set_light_brightness_tracked_state"
ATTR_POWER_STATE = "power_state"

View File

@@ -8,6 +8,7 @@ from typing import Any
from aiohttp.client_exceptions import ClientResponseError
from bond_async import Action, DeviceType, Direction
import voluptuous as vol
from homeassistant.components.fan import (
DIRECTION_FORWARD,
@@ -17,6 +18,7 @@ from homeassistant.components.fan import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
@@ -25,6 +27,7 @@ from homeassistant.util.percentage import (
from homeassistant.util.scaling import int_states_in_range
from . import BondConfigEntry
from .const import SERVICE_SET_FAN_SPEED_TRACKED_STATE
from .entity import BondEntity
from .models import BondData
from .utils import BondDevice
@@ -41,6 +44,12 @@ async def async_setup_entry(
) -> None:
"""Set up Bond fan devices."""
data = entry.runtime_data
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_FAN_SPEED_TRACKED_STATE,
{vol.Required("speed"): vol.All(vol.Number(scale=0), vol.Range(0, 100))},
"async_set_speed_belief",
)
async_add_entities(
BondFan(data, device)

View File

@@ -7,20 +7,37 @@ from typing import Any
from aiohttp.client_exceptions import ClientResponseError
from bond_async import Action, DeviceType
import voluptuous as vol
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BondConfigEntry
from .const import (
ATTR_POWER_STATE,
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE,
SERVICE_SET_LIGHT_POWER_TRACKED_STATE,
)
from .entity import BondEntity
from .models import BondData
from .utils import BondDevice
_LOGGER = logging.getLogger(__name__)
SERVICE_START_INCREASING_BRIGHTNESS = "start_increasing_brightness"
SERVICE_START_DECREASING_BRIGHTNESS = "start_decreasing_brightness"
SERVICE_STOP = "stop"
ENTITY_SERVICES = [
SERVICE_START_INCREASING_BRIGHTNESS,
SERVICE_START_DECREASING_BRIGHTNESS,
SERVICE_STOP,
]
async def async_setup_entry(
hass: HomeAssistant,
@@ -31,6 +48,14 @@ async def async_setup_entry(
data = entry.runtime_data
hub = data.hub
platform = entity_platform.async_get_current_platform()
for service in ENTITY_SERVICES:
platform.async_register_entity_service(
service,
None,
f"async_{service}",
)
fan_lights: list[Entity] = [
BondLight(data, device)
for device in hub.devices
@@ -69,6 +94,22 @@ async def async_setup_entry(
if DeviceType.is_light(device.type)
]
platform.async_register_entity_service(
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE,
{
vol.Required(ATTR_BRIGHTNESS): vol.All(
vol.Number(scale=0), vol.Range(0, 255)
)
},
"async_set_brightness_belief",
)
platform.async_register_entity_service(
SERVICE_SET_LIGHT_POWER_TRACKED_STATE,
{vol.Required(ATTR_POWER_STATE): vol.All(cv.boolean)},
"async_set_power_belief",
)
async_add_entities(
fan_lights + fan_up_lights + fan_down_lights + fireplaces + fp_lights + lights,
)

View File

@@ -1,101 +0,0 @@
"""Support for Bond services."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
ATTR_POWER_STATE = "power_state"
# Fan
SERVICE_SET_FAN_SPEED_TRACKED_STATE = "set_fan_speed_tracked_state"
# Switch
SERVICE_SET_POWER_TRACKED_STATE = "set_switch_power_tracked_state"
# Light
SERVICE_SET_LIGHT_POWER_TRACKED_STATE = "set_light_power_tracked_state"
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE = "set_light_brightness_tracked_state"
SERVICE_START_INCREASING_BRIGHTNESS = "start_increasing_brightness"
SERVICE_START_DECREASING_BRIGHTNESS = "start_decreasing_brightness"
SERVICE_STOP = "stop"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
# Fan entity services
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_FAN_SPEED_TRACKED_STATE,
entity_domain=FAN_DOMAIN,
schema={vol.Required("speed"): vol.All(vol.Number(scale=0), vol.Range(0, 100))},
func="async_set_speed_belief",
)
# Light entity services
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_START_INCREASING_BRIGHTNESS,
entity_domain=LIGHT_DOMAIN,
schema=None,
func="async_start_increasing_brightness",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_START_DECREASING_BRIGHTNESS,
entity_domain=LIGHT_DOMAIN,
schema=None,
func="async_start_decreasing_brightness",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_STOP,
entity_domain=LIGHT_DOMAIN,
schema=None,
func="async_stop",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE,
entity_domain=LIGHT_DOMAIN,
schema={
vol.Required(ATTR_BRIGHTNESS): vol.All(
vol.Number(scale=0), vol.Range(0, 255)
)
},
func="async_set_brightness_belief",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_LIGHT_POWER_TRACKED_STATE,
entity_domain=LIGHT_DOMAIN,
schema={vol.Required(ATTR_POWER_STATE): vol.All(cv.boolean)},
func="async_set_power_belief",
)
# Switch entity services
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_POWER_TRACKED_STATE,
entity_domain=SWITCH_DOMAIN,
schema={vol.Required(ATTR_POWER_STATE): cv.boolean},
func="async_set_power_belief",
)

View File

@@ -6,13 +6,16 @@ from typing import Any
from aiohttp.client_exceptions import ClientResponseError
from bond_async import Action, DeviceType
import voluptuous as vol
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BondConfigEntry
from .const import ATTR_POWER_STATE, SERVICE_SET_POWER_TRACKED_STATE
from .entity import BondEntity
@@ -23,6 +26,12 @@ async def async_setup_entry(
) -> None:
"""Set up Bond generic devices."""
data = entry.runtime_data
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SET_POWER_TRACKED_STATE,
{vol.Required(ATTR_POWER_STATE): cv.boolean},
"async_set_power_belief",
)
async_add_entities(
BondSwitch(data, device)

View File

@@ -1,3 +1,14 @@
"""Constants for the Bring! integration."""
from typing import Final
DOMAIN = "bring"
ATTR_SENDER: Final = "sender"
ATTR_ITEM_NAME: Final = "item"
ATTR_NOTIFICATION_TYPE: Final = "message"
ATTR_REACTION: Final = "reaction"
ATTR_ACTIVITY: Final = "uuid"
ATTR_RECEIVER: Final = "publicUserUuid"
SERVICE_PUSH_NOTIFICATION = "send_message"
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"

View File

@@ -1,5 +1,6 @@
"""Actions for Bring! integration."""
import logging
from typing import TYPE_CHECKING
from bring_api import (
@@ -12,28 +13,22 @@ from bring_api import (
import voluptuous as vol
from homeassistant.components.event import ATTR_EVENT_TYPE
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
entity_registry as er,
service,
)
from homeassistant.helpers import config_validation as cv, entity_registry as er
from .const import DOMAIN
from .const import (
ATTR_ACTIVITY,
ATTR_REACTION,
ATTR_RECEIVER,
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
)
from .coordinator import BringConfigEntry
ATTR_ACTIVITY = "uuid"
ATTR_ITEM_NAME = "item"
ATTR_NOTIFICATION_TYPE = "message"
ATTR_REACTION = "reaction"
ATTR_RECEIVER = "publicUserUuid"
SERVICE_PUSH_NOTIFICATION = "send_message"
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"
_LOGGER = logging.getLogger(__name__)
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema(
{
@@ -59,7 +54,6 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry:
return entry
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Bring! integration."""
@@ -114,17 +108,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
async_send_activity_stream_reaction,
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_PUSH_NOTIFICATION,
entity_domain=TODO_DOMAIN,
schema={
vol.Required(ATTR_NOTIFICATION_TYPE): vol.All(
vol.Upper, vol.Coerce(BringNotificationType)
),
vol.Optional(ATTR_ITEM_NAME): cv.string,
},
func="async_send_message",
)

View File

@@ -13,6 +13,7 @@ from bring_api import (
BringNotificationType,
BringRequestException,
)
import voluptuous as vol
from homeassistant.components.todo import (
TodoItem,
@@ -22,9 +23,15 @@ from homeassistant.components.todo import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .const import (
ATTR_ITEM_NAME,
ATTR_NOTIFICATION_TYPE,
DOMAIN,
SERVICE_PUSH_NOTIFICATION,
)
from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator
from .entity import BringBaseEntity
@@ -56,6 +63,19 @@ async def async_setup_entry(
coordinator.async_add_listener(add_entities)
add_entities()
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_PUSH_NOTIFICATION,
{
vol.Required(ATTR_NOTIFICATION_TYPE): vol.All(
vol.Upper, vol.Coerce(BringNotificationType)
),
vol.Optional(ATTR_ITEM_NAME): cv.string,
},
"async_send_message",
)
class BringTodoListEntity(BringBaseEntity, TodoListEntity):
"""A To-do List representation of the Bring! Shopping List."""

View File

@@ -15,7 +15,7 @@ from homeassistant.components.bluetooth import (
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceRegistry
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util.signal_type import SignalType
@@ -36,45 +36,6 @@ PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SE
_LOGGER = logging.getLogger(__name__)
def get_encryption_issue_id(entry_id: str) -> str:
"""Return the repair issue id for encryption removal."""
return f"encryption_removed_{entry_id}"
def _async_create_encryption_downgrade_issue(
hass: HomeAssistant, entry: BTHomeConfigEntry, issue_id: str
) -> None:
"""Create a repair issue for encryption downgrade."""
_LOGGER.warning(
"BTHome device %s was previously encrypted but is now sending "
"unencrypted data. This could be a spoofing attempt. "
"Data will be ignored until resolved",
entry.title,
)
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.WARNING,
translation_key="encryption_removed",
translation_placeholders={"name": entry.title},
data={"entry_id": entry.entry_id},
)
def _async_clear_encryption_downgrade_issue(
hass: HomeAssistant, entry: BTHomeConfigEntry, issue_id: str
) -> None:
"""Clear the encryption downgrade repair issue."""
ir.async_delete_issue(hass, DOMAIN, issue_id)
_LOGGER.info(
"BTHome device %s is now sending encrypted data again. Resuming normal operation",
entry.title,
)
def process_service_info(
hass: HomeAssistant,
entry: BTHomeConfigEntry,
@@ -84,26 +45,7 @@ def process_service_info(
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
coordinator = entry.runtime_data
data = coordinator.device_data
issue_registry = ir.async_get(hass)
issue_id = get_encryption_issue_id(entry.entry_id)
update = data.update(service_info)
# Block unencrypted payloads for devices that were previously verified as encrypted.
if entry.data.get(CONF_BINDKEY) and data.downgrade_detected:
if not coordinator.encryption_downgrade_logged:
coordinator.encryption_downgrade_logged = True
if not issue_registry.async_get_issue(DOMAIN, issue_id):
_async_create_encryption_downgrade_issue(hass, entry, issue_id)
return SensorUpdate(title=None, devices={})
if data.bindkey_verified and (
(existing_issue := issue_registry.async_get_issue(DOMAIN, issue_id))
or coordinator.encryption_downgrade_logged
):
coordinator.encryption_downgrade_logged = False
if existing_issue:
_async_clear_encryption_downgrade_issue(hass, entry, issue_id)
discovered_event_classes = coordinator.discovered_event_classes
if entry.data.get(CONF_SLEEPY_DEVICE, False) != data.sleepy_device:
hass.config_entries.async_update_entry(
@@ -208,8 +150,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry(hass: HomeAssistant, entry: BTHomeConfigEntry) -> None:
"""Remove a config entry."""
ir.async_delete_issue(hass, DOMAIN, get_encryption_issue_id(entry.entry_id))

View File

@@ -41,8 +41,6 @@ class BTHomePassiveBluetoothProcessorCoordinator(
self.discovered_event_classes = discovered_event_classes
self.device_data = device_data
self.entry = entry
# Track whether we've already logged the encryption downgrade this session.
self.encryption_downgrade_logged = False
@property
def sleepy_device(self) -> bool:

View File

@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.17.0"]
"requirements": ["bthome-ble==3.16.0"]
}

View File

@@ -1,65 +0,0 @@
"""Repairs for the BTHome integration."""
from __future__ import annotations
from typing import Any
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from . import get_encryption_issue_id
from .const import CONF_BINDKEY, DOMAIN
class EncryptionRemovedRepairFlow(RepairsFlow):
"""Handle the repair flow when encryption is disabled."""
def __init__(self, entry_id: str, entry_title: str) -> None:
"""Initialize the repair flow."""
self._entry_id = entry_id
self._entry_title = entry_title
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the initial step of the repair flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
"""Handle confirmation, remove the bindkey, and reload the entry."""
if user_input is not None:
entry = self.hass.config_entries.async_get_entry(self._entry_id)
if not entry:
return self.async_abort(reason="entry_removed")
new_data = {k: v for k, v in entry.data.items() if k != CONF_BINDKEY}
self.hass.config_entries.async_update_entry(entry, data=new_data)
ir.async_delete_issue(
self.hass, DOMAIN, get_encryption_issue_id(self._entry_id)
)
await self.hass.config_entries.async_reload(self._entry_id)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="confirm",
description_placeholders={"name": self._entry_title},
)
async def async_create_fix_flow(
hass: HomeAssistant, issue_id: str, data: dict[str, Any] | None
) -> RepairsFlow:
"""Create the repair flow for removing the encryption key."""
if not data or "entry_id" not in data:
raise ValueError("Missing data for repair flow")
entry_id = data["entry_id"]
entry = hass.config_entries.async_get_entry(entry_id)
entry_title = entry.title if entry else "Unknown device"
return EncryptionRemovedRepairFlow(entry_id, entry_title)

View File

@@ -117,21 +117,5 @@
"name": "UV Index"
}
}
},
"issues": {
"encryption_removed": {
"fix_flow": {
"abort": {
"entry_removed": "The device has been removed"
},
"step": {
"confirm": {
"description": "The BTHome device **{name}** was configured with encryption but is now broadcasting unencrypted data. Data from this device is being ignored until this is resolved.\n\nIf you disabled encryption on the device, select **Submit** to remove the encryption key and resume receiving data.\n\nIf you did not disable encryption, someone may be attempting to spoof your device. Do not submit this form and the unencrypted data will continue to be ignored.",
"title": "Remove encryption key for {name}"
}
}
},
"title": "Encryption disabled on {name}"
}
}
}

View File

@@ -506,8 +506,6 @@ def is_offset_reached(
class CalendarEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes calendar entities."""
initial_color: str | None = None
class CalendarEntity(Entity):
"""Base class for calendar event entities."""
@@ -518,16 +516,12 @@ class CalendarEntity(Entity):
_alarm_unsubs: list[CALLBACK_TYPE] | None = None
_attr_initial_color: str | None
_attr_initial_color: str | None = None
@property
def initial_color(self) -> str | None:
"""Return the initial color for the calendar entity."""
if hasattr(self, "_attr_initial_color"):
return self._attr_initial_color
if hasattr(self, "entity_description"):
return self.entity_description.initial_color
return None
return self._attr_initial_color
def get_initial_entity_options(self) -> er.EntityOptionsType | None:
"""Return initial entity options."""

View File

@@ -50,11 +50,11 @@
"selector": {},
"services": {
"disable_motion_detection": {
"description": "Disables the motion detection of a camera.",
"description": "Disables the motion detection.",
"name": "Disable motion detection"
},
"enable_motion_detection": {
"description": "Enables the motion detection of a camera.",
"description": "Enables the motion detection.",
"name": "Enable motion detection"
},
"play_stream": {
@@ -100,11 +100,11 @@
"name": "Take snapshot"
},
"turn_off": {
"description": "Turns off a camera.",
"description": "Turns off the camera.",
"name": "[%key:common::action::turn_off%]"
},
"turn_on": {
"description": "Turns on a camera.",
"description": "Turns on the camera.",
"name": "[%key:common::action::turn_on%]"
}
},

View File

@@ -49,7 +49,6 @@ from .const import ( # noqa: F401
ATTR_SWING_HORIZONTAL_MODES,
ATTR_SWING_MODE,
ATTR_SWING_MODES,
ATTR_TARGET_HUMIDITY_STEP,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TARGET_TEMP_STEP,
@@ -235,7 +234,6 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"max_temp",
"min_humidity",
"max_humidity",
"target_humidity_step",
}
@@ -251,7 +249,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
ATTR_MAX_TEMP,
ATTR_MIN_HUMIDITY,
ATTR_MAX_HUMIDITY,
ATTR_TARGET_HUMIDITY_STEP,
ATTR_TARGET_TEMP_STEP,
ATTR_PRESET_MODES,
}
@@ -278,7 +275,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_swing_horizontal_mode: str | None
_attr_swing_horizontal_modes: list[str] | None
_attr_target_humidity: float | None = None
_attr_target_humidity_step: int | None = None
_attr_target_temperature_high: float | None
_attr_target_temperature_low: float | None
_attr_target_temperature_step: float | None = None
@@ -327,9 +323,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
data[ATTR_MIN_HUMIDITY] = self.min_humidity
data[ATTR_MAX_HUMIDITY] = self.max_humidity
if self.target_humidity_step is not None:
data[ATTR_TARGET_HUMIDITY_STEP] = self.target_humidity_step
if ClimateEntityFeature.FAN_MODE in supported_features:
data[ATTR_FAN_MODES] = self.fan_modes
@@ -735,11 +728,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the maximum humidity."""
return self._attr_max_humidity
@cached_property
def target_humidity_step(self) -> int | None:
"""Return the supported step of humidity."""
return self._attr_target_humidity_step
async def async_service_humidity_set(
entity: ClimateEntity, service_call: ServiceCall

View File

@@ -114,7 +114,6 @@ ATTR_SWING_MODES = "swing_modes"
ATTR_SWING_MODE = "swing_mode"
ATTR_SWING_HORIZONTAL_MODE = "swing_horizontal_mode"
ATTR_SWING_HORIZONTAL_MODES = "swing_horizontal_modes"
ATTR_TARGET_HUMIDITY_STEP = "target_humidity_step"
ATTR_TARGET_TEMP_HIGH = "target_temp_high"
ATTR_TARGET_TEMP_LOW = "target_temp_low"
ATTR_TARGET_TEMP_STEP = "target_temp_step"

View File

@@ -21,7 +21,7 @@ async def fetch_latest_carbon_intensity(
em: ElectricityMaps,
config: Mapping[str, Any],
) -> HomeAssistantCarbonIntensityResponse:
"""Fetch the latest carbon intensity based on zone key or location coordinates."""
"""Fetch the latest carbon intensity based on country code or location coordinates."""
request: CoordinatesRequest | ZoneRequest = CoordinatesRequest(
lat=config.get(CONF_LATITUDE, hass.config.latitude),
lon=config.get(CONF_LONGITUDE, hass.config.longitude),

View File

@@ -5,7 +5,7 @@
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_data": "No data is available for the location or zone you have selected.",
"no_data": "No data is available for the location you have selected.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
@@ -17,20 +17,20 @@
},
"country": {
"data": {
"country_code": "Zone key"
"country_code": "Country code"
}
},
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
"api_key": "[%key:common::config_flow::data::access_token%]"
}
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"api_key": "[%key:common::config_flow::data::access_token%]",
"location": "[%key:common::config_flow::data::location%]"
},
"description": "Visit the [Electricity Maps app]({register_link}) to request an API key."
"description": "Visit the [Electricity Maps page]({register_link}) to request a token."
}
}
},
@@ -40,7 +40,7 @@
"name": "CO2 intensity",
"state_attributes": {
"country_code": {
"name": "Zone key"
"name": "Country code"
}
}
},
@@ -58,7 +58,7 @@
"location": {
"options": {
"specify_coordinates": "Specify coordinates",
"specify_country_code": "Specify zone key",
"specify_country_code": "Specify country code",
"use_home_location": "Use home location"
}
}

View File

@@ -335,18 +335,20 @@ def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str,
"""Return config intents."""
intents = config.get(DOMAIN, {}).get("intents", {})
return {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in intents.items()
}
for intent_name, sentences in intents.items()
}

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Callable
import dataclasses
import logging
from typing import TYPE_CHECKING, Any
@@ -19,7 +18,7 @@ from homeassistant.core import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent, singleton
from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT, IntentSource
from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT
from .entity import ConversationEntity
from .models import (
AbstractConversationAgent,
@@ -35,11 +34,9 @@ from .trace import (
_LOGGER = logging.getLogger(__name__)
TRIGGER_INTENT_NAME_PREFIX = "HassSentenceTrigger"
if TYPE_CHECKING:
from .default_agent import DefaultAgent
from .trigger import TRIGGER_CALLBACK_TYPE
from .trigger import TriggerDetails
@singleton.singleton("conversation_agent")
@@ -142,10 +139,6 @@ async def async_converse(
return result
type IntentSourceConfig = dict[str, dict[str, Any]]
type IntentsCallback = Callable[[dict[IntentSource, IntentSourceConfig]], None]
class AgentManager:
"""Class to manage conversation agents."""
@@ -154,13 +147,8 @@ class AgentManager:
self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {}
self.default_agent: DefaultAgent | None = None
self._intents: dict[IntentSource, IntentSourceConfig] = {
IntentSource.CONFIG: {"intents": {}},
IntentSource.TRIGGER: {"intents": {}},
}
self._intents_subscribers: list[IntentsCallback] = []
self._trigger_callbacks: dict[int, TRIGGER_CALLBACK_TYPE] = {}
self._trigger_callback_counter: int = 0
self.config_intents: dict[str, Any] = {}
self.triggers_details: list[TriggerDetails] = []
@callback
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
@@ -212,75 +200,27 @@ class AgentManager:
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
"""Set up the default agent."""
agent.update_config_intents(self.config_intents)
agent.update_triggers(self.triggers_details)
self.default_agent = agent
@callback
def subscribe_intents(self, subscriber: IntentsCallback) -> CALLBACK_TYPE:
"""Subscribe to intents updates.
The subscriber callback is called immediately with all intent sources
and whenever intents are updated (only with the changed source).
"""
subscriber(self._intents)
self._intents_subscribers.append(subscriber)
@callback
def unsubscribe() -> None:
"""Unsubscribe from intents updates."""
self._intents_subscribers.remove(subscriber)
return unsubscribe
def _notify_intents_subscribers(self, source: IntentSource) -> None:
"""Notify all intents subscribers of a change to a specific source."""
update = {source: self._intents[source]}
for subscriber in self._intents_subscribers:
subscriber(update)
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self._intents[IntentSource.CONFIG]["intents"] = intents
self._notify_intents_subscribers(IntentSource.CONFIG)
self.config_intents = intents
if self.default_agent is not None:
self.default_agent.update_config_intents(intents)
def register_trigger(
self, sentences: list[str], trigger_callback: TRIGGER_CALLBACK_TYPE
) -> CALLBACK_TYPE:
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
"""Register a trigger."""
trigger_id = self._trigger_callback_counter
self._trigger_callback_counter += 1
trigger_intent_name = f"{TRIGGER_INTENT_NAME_PREFIX}{trigger_id}"
trigger_intents = self._intents[IntentSource.TRIGGER]
trigger_intents["intents"][trigger_intent_name] = {
"data": [{"sentences": sentences}]
}
self._trigger_callbacks[trigger_id] = trigger_callback
self._notify_intents_subscribers(IntentSource.TRIGGER)
self.triggers_details.append(trigger_details)
if self.default_agent is not None:
self.default_agent.update_triggers(self.triggers_details)
@callback
def unregister_trigger() -> None:
"""Unregister the trigger."""
del trigger_intents["intents"][trigger_intent_name]
del self._trigger_callbacks[trigger_id]
self._notify_intents_subscribers(IntentSource.TRIGGER)
self.triggers_details.remove(trigger_details)
if self.default_agent is not None:
self.default_agent.update_triggers(self.triggers_details)
return unregister_trigger
@property
def trigger_sentences(self) -> list[str]:
"""Get all trigger sentences."""
sentences: list[str] = []
trigger_intents = self._intents[IntentSource.TRIGGER]
for trigger_intent in trigger_intents.get("intents", {}).values():
for data in trigger_intent.get("data", []):
sentences.extend(data.get("sentences", []))
return sentences
def get_trigger_callback(
self, trigger_intent_name: str
) -> TRIGGER_CALLBACK_TYPE | None:
"""Get the callback for a trigger from its intent name."""
if not trigger_intent_name.startswith(TRIGGER_INTENT_NAME_PREFIX):
return None
trigger_id = int(trigger_intent_name[len(TRIGGER_INTENT_NAME_PREFIX) :])
return self._trigger_callbacks.get(trigger_id)

View File

@@ -36,13 +36,6 @@ METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
class IntentSource(StrEnum):
"""Source of intents."""
CONFIG = "config"
TRIGGER = "trigger"
class ChatLogEventType(StrEnum):
"""Chat log event type."""

View File

@@ -76,18 +76,18 @@ from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.util import language as language_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import IntentSourceConfig, get_agent_manager
from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog, ToolResultContent
from .const import (
DOMAIN,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
ConversationEntityFeature,
IntentSource,
)
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append
from .trigger import TriggerDetails
_LOGGER = logging.getLogger(__name__)
@@ -126,7 +126,7 @@ class SentenceTriggerResult:
sentence: str
sentence_template: str | None
matched_triggers: dict[str, RecognizeResult]
matched_triggers: dict[int, RecognizeResult]
class IntentMatchingStage(Enum):
@@ -236,19 +236,15 @@ class DefaultAgent(ConversationEntity):
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the default agent."""
self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {}
self._load_intents_lock = asyncio.Lock()
# Intents from common conversation config
self._config_intents_config: IntentSourceConfig = {}
self._config_intents: dict[str, Any] = {}
# Intents from conversation triggers
# Sentences that will trigger a callback (skipping intent recognition)
self._triggers_details: list[TriggerDetails] = []
self._trigger_intents: Intents | None = None
self._trigger_intents_config: IntentSourceConfig = {}
# Subscription to intents updates
self._unsub_intents: Callable[[], None] | None = None
# Slot lists for entities, areas, etc.
self._slot_lists: dict[str, SlotList] | None = None
@@ -265,33 +261,6 @@ class DefaultAgent(ConversationEntity):
self.fuzzy_matching = True
self._fuzzy_config: FuzzyConfig | None = None
async def async_added_to_hass(self) -> None:
"""Subscribe to intents updates when added to hass."""
self._unsub_intents = get_agent_manager(self.hass).subscribe_intents(
self._update_intents
)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from intents updates when removed from hass."""
if self._unsub_intents is not None:
self._unsub_intents()
self._unsub_intents = None
@callback
def _update_intents(
self, intents_update: dict[IntentSource, IntentSourceConfig]
) -> None:
"""Handle intents update from agent_manager subscription."""
if IntentSource.CONFIG in intents_update:
self._config_intents_config = intents_update[IntentSource.CONFIG]
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
if IntentSource.TRIGGER in intents_update:
self._trigger_intents_config = intents_update[IntentSource.TRIGGER]
# Force rebuild on next use
self._trigger_intents = None
@property
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
@@ -1090,6 +1059,14 @@ class DefaultAgent(ConversationEntity):
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
@callback
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self._config_intents = intents
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
async def async_prepare(self, language: str | None = None) -> None:
"""Load intents for a language."""
if language is None:
@@ -1216,7 +1193,7 @@ class DefaultAgent(ConversationEntity):
merge_dict(
intents_dict,
self._config_intents_config,
self._config_intents,
)
if not intents_dict:
@@ -1484,12 +1461,27 @@ class DefaultAgent(ConversationEntity):
return response_template.async_render(response_args)
@callback
def update_triggers(self, triggers_details: list[TriggerDetails]) -> None:
"""Update triggers."""
self._triggers_details = triggers_details
# Force rebuild on next use
self._trigger_intents = None
def _rebuild_trigger_intents(self) -> None:
"""Rebuild the HassIL intents object from the trigger intents dict."""
"""Rebuild the HassIL intents object from the current trigger sentences."""
intents_dict = {
"language": self.hass.config.language,
**self._trigger_intents_config,
"intents": {
# Use trigger data index as a virtual intent name for HassIL.
# This works because the intents are rebuilt on every
# register/unregister.
str(trigger_id): {"data": [{"sentences": trigger_details.sentences}]}
for trigger_id, trigger_details in enumerate(self._triggers_details)
},
}
trigger_intents = Intents.from_dict(intents_dict)
# Assume slot list references are wildcards
@@ -1504,7 +1496,7 @@ class DefaultAgent(ConversationEntity):
self._trigger_intents = trigger_intents
_LOGGER.debug("Rebuilt trigger intents: %s", self._trigger_intents_config)
_LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
async def async_recognize_sentence_trigger(
self, user_input: ConversationInput
@@ -1514,7 +1506,7 @@ class DefaultAgent(ConversationEntity):
Calls the registered callbacks if there's a match and returns a sentence
trigger result.
"""
if not self._trigger_intents_config.get("intents"):
if not self._triggers_details:
# No triggers registered
return None
@@ -1524,18 +1516,18 @@ class DefaultAgent(ConversationEntity):
assert self._trigger_intents is not None
matched_triggers: dict[str, RecognizeResult] = {}
matched_triggers: dict[int, RecognizeResult] = {}
matched_template: str | None = None
for result in recognize_all(user_input.text, self._trigger_intents):
if result.intent_sentence is not None:
matched_template = result.intent_sentence.text
trigger_intent_name = result.intent.name
if trigger_intent_name in matched_triggers:
trigger_id = int(result.intent.name)
if trigger_id in matched_triggers:
# Already matched a sentence from this trigger
break
matched_triggers[trigger_intent_name] = result
matched_triggers[trigger_id] = result
if not matched_triggers:
# Sentence did not match any trigger sentences
@@ -1559,14 +1551,10 @@ class DefaultAgent(ConversationEntity):
chat_log: ChatLog,
) -> str:
"""Run sentence trigger callbacks and return response text."""
manager = get_agent_manager(self.hass)
# Gather callback responses in parallel
trigger_callbacks = [
trigger_callback(user_input, trigger_result)
for trigger_intent_name, trigger_result in result.matched_triggers.items()
if (trigger_callback := manager.get_trigger_callback(trigger_intent_name))
is not None
self._triggers_details[trigger_id].callback(user_input, trigger_result)
for trigger_id, trigger_result in result.matched_triggers.items()
]
tool_input = llm.ToolInput(

View File

@@ -165,7 +165,11 @@ async def websocket_list_sentences(
"""List custom registered sentences."""
manager = get_agent_manager(hass)
connection.send_result(msg["id"], {"trigger_sentences": manager.trigger_sentences})
sentences = []
for trigger_details in manager.triggers_details:
sentences.extend(trigger_details.sentences)
connection.send_result(msg["id"], {"trigger_sentences": sentences})
@websocket_api.websocket_command(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.3"]
}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from hassil.recognize import RecognizeResult
@@ -30,6 +31,14 @@ TRIGGER_CALLBACK_TYPE = Callable[
]
@dataclass(slots=True)
class TriggerDetails:
"""List of sentences and the callback for a trigger."""
sentences: list[str]
callback: TRIGGER_CALLBACK_TYPE
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
@@ -140,5 +149,5 @@ async def async_attach_trigger(
return None
return get_agent_manager(hass).register_trigger(
sentences=sentences, trigger_callback=call_action
TriggerDetails(sentences=sentences, callback=call_action)
)

View File

@@ -3,8 +3,9 @@
import logging
from datadog import DogStatsd, initialize
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PORT,
@@ -15,15 +16,53 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, state as state_helper
from homeassistant.helpers.typing import ConfigType
from . import config_flow as config_flow
from .const import CONF_RATE, DOMAIN
from .const import (
CONF_RATE,
DEFAULT_HOST,
DEFAULT_PORT,
DEFAULT_PREFIX,
DEFAULT_RATE,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
type DatadogConfigEntry = ConfigEntry[DogStatsd]
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string,
vol.Optional(CONF_RATE, default=DEFAULT_RATE): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Datadog integration from YAML, initiating config flow import."""
if DOMAIN not in config:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config[DOMAIN],
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: DatadogConfigEntry) -> bool:

View File

@@ -12,7 +12,8 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PREFIX
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import (
CONF_RATE,
@@ -70,6 +71,22 @@ class DatadogConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from configuration.yaml."""
# Check for duplicates
self._async_abort_entries_match(
{CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
)
result = await self.async_step_user(user_input)
if errors := result.get("errors"):
await deprecate_yaml_issue(self.hass, False)
return self.async_abort(reason=errors["base"])
await deprecate_yaml_issue(self.hass, True)
return result
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
@@ -146,3 +163,41 @@ async def validate_datadog_connection(
return False
else:
return True
async def deprecate_yaml_issue(
hass: HomeAssistant,
import_success: bool,
) -> None:
"""Create an issue to deprecate YAML config."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.2.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Datadog",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_connection_error",
breaks_in_ha_version="2026.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_connection_error",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Datadog",
"url": f"/config/integrations/dashboard/add?domain={DOMAIN}",
},
)

View File

@@ -25,6 +25,12 @@
}
}
},
"issues": {
"deprecated_yaml_import_connection_error": {
"description": "There was an error connecting to the Datadog Agent when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the {domain} configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "{domain} YAML configuration import failed"
}
},
"options": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",

View File

@@ -67,7 +67,6 @@ async def async_setup_entry(
target_temp_high=None,
target_temp_low=None,
hvac_modes=[cls for cls in HVACMode if cls != HVACMode.HEAT_COOL],
target_humidity_step=5,
),
DemoClimate(
unique_id="climate_3",
@@ -119,7 +118,6 @@ class DemoClimate(ClimateEntity):
target_temp_low: float | None,
hvac_modes: list[HVACMode],
preset_modes: list[str] | None = None,
target_humidity_step: int | None = None,
) -> None:
"""Initialize the climate device."""
self._unique_id = unique_id
@@ -165,7 +163,6 @@ class DemoClimate(ClimateEntity):
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_target_humidity_step = target_humidity_step
@property
def unique_id(self) -> str:

View File

@@ -9,9 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_SHOW_ALL_SOURCES,
@@ -25,12 +24,9 @@ from .const import (
DEFAULT_USE_TELNET,
DEFAULT_ZONE2,
DEFAULT_ZONE3,
DOMAIN,
)
from .receiver import ConnectDenonAVR
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
@@ -38,12 +34,6 @@ _LOGGER = logging.getLogger(__name__)
type DenonavrConfigEntry = ConfigEntry[DenonAVR]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> bool:
"""Set up the denonavr components from a config entry."""
# Connect to receiver

View File

@@ -2,7 +2,6 @@
DOMAIN = "denonavr"
ATTR_DYNAMIC_EQ = "dynamic_eq"
CONF_SHOW_ALL_SOURCES = "show_all_sources"
CONF_ZONE2 = "zone2"

View File

@@ -26,6 +26,7 @@ from denonavr.exceptions import (
AvrTimoutError,
DenonAvrError,
)
import voluptuous as vol
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
@@ -34,14 +35,14 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE
from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DenonavrConfigEntry
from .const import (
ATTR_DYNAMIC_EQ,
CONF_MANUFACTURER,
CONF_SERIAL_NUMBER,
CONF_UPDATE_AUDYSSEY,
@@ -52,6 +53,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
ATTR_SOUND_MODE_RAW = "sound_mode_raw"
ATTR_DYNAMIC_EQ = "dynamic_eq"
SUPPORT_DENON = (
MediaPlayerEntityFeature.VOLUME_STEP
@@ -74,6 +76,11 @@ SUPPORT_MEDIA_MODES = (
SCAN_INTERVAL = timedelta(seconds=10)
PARALLEL_UPDATES = 1
# Services
SERVICE_GET_COMMAND = "get_command"
SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq"
SERVICE_UPDATE_AUDYSSEY = "update_audyssey"
# HA Telnet events
TELNET_EVENTS = {
"HD",
@@ -127,6 +134,24 @@ async def async_setup_entry(
"%s receiver at host %s initialized", receiver.manufacturer, receiver.host
)
# Register additional services
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_GET_COMMAND,
{vol.Required(ATTR_COMMAND): cv.string},
f"async_{SERVICE_GET_COMMAND}",
)
platform.async_register_entity_service(
SERVICE_SET_DYNAMIC_EQ,
{vol.Required(ATTR_DYNAMIC_EQ): cv.boolean},
f"async_{SERVICE_SET_DYNAMIC_EQ}",
)
platform.async_register_entity_service(
SERVICE_UPDATE_AUDYSSEY,
None,
f"async_{SERVICE_UPDATE_AUDYSSEY}",
)
async_add_entities(entities, update_before_add=True)

View File

@@ -1,47 +0,0 @@
"""Support for Denon AVR receivers using their HTTP interface."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.const import ATTR_COMMAND
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_DYNAMIC_EQ, DOMAIN
# Services
SERVICE_GET_COMMAND = "get_command"
SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq"
SERVICE_UPDATE_AUDYSSEY = "update_audyssey"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_GET_COMMAND,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_COMMAND): cv.string},
func=f"async_{SERVICE_GET_COMMAND}",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SET_DYNAMIC_EQ,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_DYNAMIC_EQ): cv.boolean},
func=f"async_{SERVICE_SET_DYNAMIC_EQ}",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_UPDATE_AUDYSSEY,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func=f"async_{SERVICE_UPDATE_AUDYSSEY}",
)

View File

@@ -7,7 +7,10 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_SOURCE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device import async_entity_id_to_device_id
from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
async_remove_helper_config_entry_from_source_device,
@@ -19,6 +22,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Derivative from a config entry."""
# This can be removed in HA Core 2026.2
async_remove_stale_devices_links_keep_entity_device(
hass, entry.entry_id, entry.options[CONF_SOURCE]
)
def set_source_entity_id_or_uuid(source_entity_id: str) -> None:
hass.config_entries.async_update_entry(
entry,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime
import logging
import urllib.error
import urllib
from pyW215.pyW215 import SmartPlug

View File

@@ -46,12 +46,13 @@ class DSMRSensor(SensorEntity):
@callback
def message_received(message):
"""Handle new MQTT messages."""
if not (payload := message.payload):
if message.payload == "":
self._attr_native_value = None
elif (state := self.entity_description.state) is not None:
self._attr_native_value = state(payload)
elif self.entity_description.state is not None:
# Perform optional additional parsing
self._attr_native_value = self.entity_description.state(message.payload)
else:
self._attr_native_value = payload
self._attr_native_value = message.payload
self.async_write_ha_state()

View File

@@ -41,20 +41,13 @@ class UKFloodsFlowHandler(ConfigFlow, domain=DOMAIN):
self.stations = {}
for station in stations:
label = station["label"]
rloId = station["RLOIid"]
# API annoyingly sometimes returns a list and some times returns a string
# E.g. L3121 has a label of ['Scurf Dyke', 'Scurf Dyke Dyke Level']
if isinstance(label, list):
label = label[-1]
# Similar for RLOIid
# E.g. 0018 has an RLOIid of ['10427', '9154']
if isinstance(rloId, list):
rloId = rloId[-1]
fullName = label + " - " + rloId
self.stations[fullName] = station["stationReference"]
self.stations[label] = station["stationReference"]
if not self.stations:
return self.async_abort(reason="no_stations")

View File

@@ -5,12 +5,8 @@ from sucks import VacBot
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .controller import EcovacsController
from .services import async_setup_services
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -26,14 +22,6 @@ PLATFORMS = [
]
type EcovacsConfigEntry = ConfigEntry[EcovacsController]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool:
"""Set up this integration using UI."""

View File

@@ -1,27 +0,0 @@
"""Ecovacs services."""
from __future__ import annotations
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
from homeassistant.core import HomeAssistant, SupportsResponse, callback
from homeassistant.helpers import service
from .const import DOMAIN
SERVICE_RAW_GET_POSITIONS = "raw_get_positions"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
# Vacuum Services
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_RAW_GET_POSITIONS,
entity_domain=VACUUM_DOMAIN,
schema=None,
func="async_raw_get_positions",
supports_response=SupportsResponse.ONLY,
)

View File

@@ -18,8 +18,9 @@ from homeassistant.components.vacuum import (
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
@@ -31,6 +32,9 @@ from .util import get_name_key
_LOGGER = logging.getLogger(__name__)
ATTR_ERROR = "error"
ATTR_COMPONENT_PREFIX = "component_"
SERVICE_RAW_GET_POSITIONS = "raw_get_positions"
async def async_setup_entry(
@@ -52,6 +56,14 @@ async def async_setup_entry(
_LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums)
async_add_entities(vacuums)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_RAW_GET_POSITIONS,
None,
"async_raw_get_positions",
supports_response=SupportsResponse.ONLY,
)
class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
"""Legacy Ecovacs vacuums."""

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "platinum",
"requirements": ["eheimdigital==1.6.0"],
"requirements": ["eheimdigital==1.5.0"],
"zeroconf": [
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
]

View File

@@ -2,23 +2,12 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.BUTTON, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ElgatoConfigEntry) -> bool:
"""Set up Elgato Light from a config entry."""
coordinator = ElgatoDataUpdateCoordinator(hass, entry)

View File

@@ -14,3 +14,6 @@ SCAN_INTERVAL = timedelta(seconds=10)
# Attributes
ATTR_ON = "on"
# Services
SERVICE_IDENTIFY = "identify"

View File

@@ -15,9 +15,13 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util import color as color_util
from .const import SERVICE_IDENTIFY
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
@@ -33,6 +37,13 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities([ElgatoLight(coordinator)])
platform = async_get_current_platform()
platform.async_register_entity_service(
SERVICE_IDENTIFY,
None,
ElgatoLight.async_identify.__name__,
)
class ElgatoLight(ElgatoEntity, LightEntity):
"""Defines an Elgato Light."""

View File

@@ -1,25 +0,0 @@
"""Support for Elgato services."""
from __future__ import annotations
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import service
from .const import DOMAIN
SERVICE_IDENTIFY = "identify"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_IDENTIFY,
entity_domain=LIGHT_DOMAIN,
schema=None,
func="async_identify",
)

View File

@@ -8,11 +8,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .const import DATA_ENOCEAN, DOMAIN, ENOCEAN_DONGLE
from .dongle import EnOceanDongle
type EnOceanConfigEntry = ConfigEntry[EnOceanDongle]
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_DEVICE): cv.string})}, extra=vol.ALLOW_EXTRA
)
@@ -38,23 +36,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(
hass: HomeAssistant, config_entry: EnOceanConfigEntry
) -> bool:
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up an EnOcean dongle for the given entry."""
enocean_data = hass.data.setdefault(DATA_ENOCEAN, {})
usb_dongle = EnOceanDongle(hass, config_entry.data[CONF_DEVICE])
await usb_dongle.async_setup()
config_entry.runtime_data = usb_dongle
enocean_data[ENOCEAN_DONGLE] = usb_dongle
return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: EnOceanConfigEntry
) -> bool:
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload EnOcean config entry."""
enocean_dongle = config_entry.runtime_data
enocean_dongle = hass.data[DATA_ENOCEAN][ENOCEAN_DONGLE]
enocean_dongle.unload()
hass.data.pop(DATA_ENOCEAN)
return True

View File

@@ -5,6 +5,8 @@ import logging
from homeassistant.const import Platform
DOMAIN = "enocean"
DATA_ENOCEAN = "enocean"
ENOCEAN_DONGLE = "dongle"
ERROR_INVALID_DONGLE_PATH = "invalid_dongle_path"

View File

@@ -11,15 +11,11 @@ from epson_projector.const import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP
from .const import CONF_CONNECTION_TYPE, HTTP
from .exceptions import CannotConnect, PoweredOff
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
@@ -51,12 +47,6 @@ async def validate_projector(
return epson_proj
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool:
"""Set up epson from a config entry."""
projector = await validate_projector(

View File

@@ -1,7 +1,7 @@
"""Constants for the epson integration."""
DOMAIN = "epson"
SERVICE_SELECT_CMODE = "select_cmode"
CONF_CONNECTION_TYPE = "connection_type"
ATTR_CMODE = "cmode"

View File

@@ -27,6 +27,7 @@ from epson_projector.const import (
VOL_UP,
VOLUME,
)
import voluptuous as vol
from homeassistant.components.media_player import (
MediaPlayerEntity,
@@ -35,12 +36,17 @@ from homeassistant.components.media_player import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_platform,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EpsonConfigEntry
from .const import ATTR_CMODE, DOMAIN
from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE
_LOGGER = logging.getLogger(__name__)
@@ -57,6 +63,12 @@ async def async_setup_entry(
entry=config_entry,
)
async_add_entities([projector_entity], True)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_SELECT_CMODE,
{vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET))},
SERVICE_SELECT_CMODE,
)
class EpsonProjectorMediaPlayer(MediaPlayerEntity):

View File

@@ -1,27 +0,0 @@
"""Support for Epson projector."""
from __future__ import annotations
from epson_projector.const import CMODE_LIST_SET
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_CMODE, DOMAIN
SERVICE_SELECT_CMODE = "select_cmode"
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SELECT_CMODE,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET))},
func=SERVICE_SELECT_CMODE,
)

View File

@@ -181,7 +181,7 @@ class EvoChild(EvoEntity):
self._device_state_attrs = {
"activeFaults": self._evo_device.active_faults,
"setpoints": self.setpoints,
"setpoints": self._setpoints,
}
super()._handle_coordinator_update()

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Generator
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
@@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@contextmanager
def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Generator[Path]:
def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]:
"""Get an uploaded file.
File is removed at the end of the context.

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from collections.abc import AsyncGenerator
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, contextmanager
from datetime import timedelta
import logging
@@ -132,7 +132,7 @@ class FjaraskupanCoordinator(DataUpdateCoordinator[State]):
self.async_set_updated_data(self.device.state)
@asynccontextmanager
async def async_connect_and_update(self) -> AsyncGenerator[Device]:
async def async_connect_and_update(self) -> AsyncIterator[Device]:
"""Provide an up-to-date device for use during connections."""
if (
ble_device := async_ble_device_from_address(

View File

@@ -4,8 +4,6 @@ import asyncio.exceptions
from typing import Any
from flexit_bacnet import (
OPERATION_MODE_FIREPLACE,
OPERATION_MODE_OFF,
VENTILATION_MODE_AWAY,
VENTILATION_MODE_HOME,
VENTILATION_MODE_STOP,
@@ -14,6 +12,7 @@ from flexit_bacnet.bacnet import DecodingError
from homeassistant.components.climate import (
PRESET_AWAY,
PRESET_BOOST,
PRESET_HOME,
ClimateEntity,
ClimateEntityFeature,
@@ -29,10 +28,8 @@ from .const import (
DOMAIN,
MAX_TEMP,
MIN_TEMP,
OPERATION_TO_PRESET_MODE_MAP,
PRESET_FIREPLACE,
PRESET_HIGH,
PRESET_TO_VENTILATION_MODE_MAP,
VENTILATION_TO_PRESET_MODE_MAP,
)
from .coordinator import FlexitConfigEntry, FlexitCoordinator
from .entity import FlexitEntity
@@ -54,7 +51,6 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
"""Flexit air handling unit."""
_attr_name = None
_attr_translation_key = "flexit_bacnet"
_attr_hvac_modes = [
HVACMode.OFF,
@@ -64,8 +60,7 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
_attr_preset_modes = [
PRESET_AWAY,
PRESET_HOME,
PRESET_HIGH,
PRESET_FIREPLACE,
PRESET_BOOST,
]
_attr_supported_features = (
@@ -132,29 +127,20 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
Requires ClimateEntityFeature.PRESET_MODE.
"""
return OPERATION_TO_PRESET_MODE_MAP[self.device.operation_mode]
return VENTILATION_TO_PRESET_MODE_MAP[self.device.ventilation_mode]
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
try:
if preset_mode == PRESET_FIREPLACE:
# Use trigger method for fireplace mode
await self.device.trigger_fireplace_mode()
else:
# If currently in fireplace mode, toggle it off first
# trigger_fireplace_mode() acts as a toggle
if self.device.operation_mode == OPERATION_MODE_FIREPLACE:
await self.device.trigger_fireplace_mode()
ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode]
# Set the desired ventilation mode
ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode]
await self.device.set_ventilation_mode(ventilation_mode)
try:
await self.device.set_ventilation_mode(ventilation_mode)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_preset_mode",
translation_placeholders={
"preset": preset_mode,
"preset": str(ventilation_mode),
},
) from exc
finally:
@@ -163,7 +149,7 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode."""
if self.device.operation_mode == OPERATION_MODE_OFF:
if self.device.ventilation_mode == VENTILATION_MODE_STOP:
return HVACMode.OFF
return HVACMode.FAN_ONLY

View File

@@ -1,40 +1,34 @@
"""Constants for the Flexit Nordic (BACnet) integration."""
from flexit_bacnet import (
OPERATION_MODE_AWAY,
OPERATION_MODE_FIREPLACE,
OPERATION_MODE_HIGH,
OPERATION_MODE_HOME,
OPERATION_MODE_OFF,
VENTILATION_MODE_AWAY,
VENTILATION_MODE_HIGH,
VENTILATION_MODE_HOME,
VENTILATION_MODE_STOP,
)
from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME, PRESET_NONE
from homeassistant.components.climate import (
PRESET_AWAY,
PRESET_BOOST,
PRESET_HOME,
PRESET_NONE,
)
DOMAIN = "flexit_bacnet"
MAX_TEMP = 30
MIN_TEMP = 10
PRESET_HIGH = "high"
PRESET_FIREPLACE = "fireplace"
# Map operation mode (what device reports) to Home Assistant preset
OPERATION_TO_PRESET_MODE_MAP = {
OPERATION_MODE_OFF: PRESET_NONE,
OPERATION_MODE_AWAY: PRESET_AWAY,
OPERATION_MODE_HOME: PRESET_HOME,
OPERATION_MODE_HIGH: PRESET_HIGH,
OPERATION_MODE_FIREPLACE: PRESET_FIREPLACE,
VENTILATION_TO_PRESET_MODE_MAP = {
VENTILATION_MODE_STOP: PRESET_NONE,
VENTILATION_MODE_AWAY: PRESET_AWAY,
VENTILATION_MODE_HOME: PRESET_HOME,
VENTILATION_MODE_HIGH: PRESET_BOOST,
}
# Map preset to ventilation mode (for setting standard modes)
PRESET_TO_VENTILATION_MODE_MAP = {
PRESET_NONE: VENTILATION_MODE_STOP,
PRESET_AWAY: VENTILATION_MODE_AWAY,
PRESET_HOME: VENTILATION_MODE_HOME,
PRESET_HIGH: VENTILATION_MODE_HIGH,
PRESET_BOOST: VENTILATION_MODE_HIGH,
}

View File

@@ -1,17 +1,5 @@
{
"entity": {
"climate": {
"flexit_bacnet": {
"state_attributes": {
"preset_mode": {
"state": {
"fireplace": "mdi:fireplace",
"high": "mdi:fan-speed-3"
}
}
}
}
},
"number": {
"away_extract_fan_setpoint": {
"default": "mdi:fan-minus"

View File

@@ -26,18 +26,6 @@
"name": "Air filter polluted"
}
},
"climate": {
"flexit_bacnet": {
"state_attributes": {
"preset_mode": {
"state": {
"fireplace": "Fireplace",
"high": "[%key:common::state::high%]"
}
}
}
}
},
"number": {
"away_extract_fan_setpoint": {
"name": "Away extract fan setpoint"
@@ -151,11 +139,5 @@
"switch_turn": {
"message": "Failed to turn the switch {state}."
}
},
"issues": {
"deprecated_fireplace_switch": {
"description": "The fireplace mode switch entity `{entity_id}` is deprecated and will be removed in a future version.\n\nFireplace mode has been moved to a climate preset on the climate entity to better match the device interface.\n\nPlease update your automations to use the `climate.set_preset_mode` action with preset mode `fireplace` instead of using the switch entity.\n\nAfter updating your automations, you can safely disable this switch entity.",
"title": "Fireplace mode switch is deprecated"
}
}
}

View File

@@ -13,12 +13,9 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
from .coordinator import FlexitConfigEntry, FlexitCoordinator
@@ -42,13 +39,6 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = (
turn_on_fn=lambda data: data.enable_electric_heater(),
turn_off_fn=lambda data: data.disable_electric_heater(),
),
FlexitSwitchEntityDescription(
key="cooker_hood_mode",
translation_key="cooker_hood_mode",
is_on_fn=lambda data: data.cooker_hood_status,
turn_on_fn=lambda data: data.activate_cooker_hood(),
turn_off_fn=lambda data: data.deactivate_cooker_hood(),
),
FlexitSwitchEntityDescription(
key="fireplace_mode",
translation_key="fireplace_mode",
@@ -56,6 +46,13 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = (
turn_on_fn=lambda data: data.trigger_fireplace_mode(),
turn_off_fn=lambda data: data.trigger_fireplace_mode(),
),
FlexitSwitchEntityDescription(
key="cooker_hood_mode",
translation_key="cooker_hood_mode",
is_on_fn=lambda data: data.cooker_hood_status,
turn_on_fn=lambda data: data.activate_cooker_hood(),
turn_off_fn=lambda data: data.deactivate_cooker_hood(),
),
)
@@ -67,42 +64,9 @@ async def async_setup_entry(
"""Set up Flexit (bacnet) switch from a config entry."""
coordinator = config_entry.runtime_data
entities: list[FlexitSwitch] = []
for description in SWITCHES:
if description.key == "fireplace_mode":
# Check if deprecated fireplace switch is enabled and create repair issue
entity_reg = er.async_get(hass)
fireplace_switch_unique_id = (
f"{coordinator.device.serial_number}-fireplace_mode"
)
# Look up the fireplace switch entity by unique_id
fireplace_switch_entity_id = entity_reg.async_get_entity_id(
Platform.SWITCH, DOMAIN, fireplace_switch_unique_id
)
if not fireplace_switch_entity_id:
continue
entity_registry_entry = entity_reg.async_get(fireplace_switch_entity_id)
if entity_registry_entry:
if entity_registry_entry.disabled:
entity_reg.async_remove(fireplace_switch_entity_id)
else:
async_create_issue(
hass,
DOMAIN,
f"deprecated_switch_{fireplace_switch_unique_id}",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_fireplace_switch",
translation_placeholders={
"entity_id": fireplace_switch_entity_id,
},
)
entities.append(FlexitSwitch(coordinator, description))
else:
entities.append(FlexitSwitch(coordinator, description))
async_add_entities(entities)
async_add_entities(
FlexitSwitch(coordinator, description) for description in SWITCHES
)
PARALLEL_UPDATES = 1

View File

@@ -1,22 +1,11 @@
"""The Fressnapf Tracker integration."""
import logging
from fressnapftracker import (
ApiClient,
AuthClient,
Device,
FressnapfTrackerAuthenticationError,
FressnapfTrackerError,
FressnapfTrackerInvalidTrackerResponseError,
Tracker,
)
from fressnapftracker import AuthClient, FressnapfTrackerAuthenticationError
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import CONF_USER_ID, DOMAIN
from .coordinator import (
@@ -32,43 +21,6 @@ PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
_LOGGER = logging.getLogger(__name__)
async def _get_valid_tracker(hass: HomeAssistant, device: Device) -> Tracker | None:
"""Test if the tracker returns valid data and return it.
Malformed data might indicate the tracker is broken or hasn't been properly registered with the app.
"""
client = ApiClient(
serial_number=device.serialnumber,
device_token=device.token,
client=get_async_client(hass),
)
try:
return await client.get_tracker()
except FressnapfTrackerInvalidTrackerResponseError:
_LOGGER.warning(
"Tracker with serialnumber %s is invalid. Consider removing it via the App",
device.serialnumber,
)
async_create_issue(
hass,
DOMAIN,
f"invalid_fressnapf_tracker_{device.serialnumber}",
issue_domain=DOMAIN,
learn_more_url="https://www.home-assistant.io/integrations/fressnapf_tracker/",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="invalid_fressnapf_tracker",
translation_placeholders={
"tracker_id": device.serialnumber,
},
)
return None
except FressnapfTrackerError as err:
raise ConfigEntryNotReady(err) from err
async def async_setup_entry(
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
@@ -88,15 +40,12 @@ async def async_setup_entry(
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
for device in devices:
tracker = await _get_valid_tracker(hass, device)
if tracker is None:
continue
coordinator = FressnapfTrackerDataUpdateCoordinator(
hass,
entry,
device,
initial_data=tracker,
)
await coordinator.async_config_entry_first_refresh()
coordinators.append(coordinator)
entry.runtime_data = coordinators

View File

@@ -34,7 +34,6 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
hass: HomeAssistant,
config_entry: FressnapfTrackerConfigEntry,
device: Device,
initial_data: Tracker,
) -> None:
"""Initialize."""
super().__init__(
@@ -50,7 +49,6 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
device_token=device.token,
client=get_async_client(hass),
)
self.data = initial_data
async def _async_update_data(self) -> Tracker:
try:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["fressnapftracker==0.2.2"]
"requirements": ["fressnapftracker==0.2.1"]
}

View File

@@ -92,11 +92,5 @@
"not_seen_recently": {
"message": "The flashlight cannot be activated when the tracker has not moved recently."
}
},
"issues": {
"invalid_fressnapf_tracker": {
"description": "The Fressnapf GPS tracker with the serial number `{tracker_id}` is sending invalid data. This most likely indicates the tracker is broken or hasn't been properly registered with the app. Please remove the tracker via the Fressnapf app. You can then close this issue.",
"title": "Invalid Fressnapf GPS tracker detected"
}
}
}

View File

@@ -7,7 +7,6 @@ from functools import lru_cache, partial
import logging
import os
import pathlib
import shutil
from typing import Any, TypedDict
from aiohttp import hdrs, web, web_urldispatcher
@@ -37,7 +36,6 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.util.hass_dict import HassKey
from .pr_download import download_pr_artifact
from .storage import (
async_setup_frontend_storage,
async_system_store as async_system_store,
@@ -57,10 +55,6 @@ CONF_EXTRA_MODULE_URL = "extra_module_url"
CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5"
CONF_FRONTEND_REPO = "development_repo"
CONF_JS_VERSION = "javascript_version"
CONF_DEVELOPMENT_PR = "development_pr"
CONF_GITHUB_TOKEN = "github_token"
DEV_ARTIFACTS_DIR = "development_artifacts"
DEFAULT_THEME_COLOR = "#2980b9"
@@ -139,8 +133,6 @@ CONFIG_SCHEMA = vol.Schema(
DOMAIN: vol.Schema(
{
vol.Optional(CONF_FRONTEND_REPO): cv.isdir,
vol.Inclusive(CONF_DEVELOPMENT_PR, "development_pr"): cv.positive_int,
vol.Inclusive(CONF_GITHUB_TOKEN, "development_pr"): cv.string,
vol.Optional(CONF_THEMES): vol.All(dict, _validate_themes),
vol.Optional(CONF_EXTRA_MODULE_URL): vol.All(
cv.ensure_list, [cv.string]
@@ -433,49 +425,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
repo_path = conf.get(CONF_FRONTEND_REPO)
dev_pr_number = conf.get(CONF_DEVELOPMENT_PR)
pr_cache_dir = pathlib.Path(hass.config.cache_path(DOMAIN, DEV_ARTIFACTS_DIR))
if not dev_pr_number and pr_cache_dir.exists():
try:
await hass.async_add_executor_job(shutil.rmtree, pr_cache_dir)
_LOGGER.debug("Cleaned up frontend development artifacts")
except OSError as err:
_LOGGER.warning(
"Could not clean up frontend development artifacts: %s", err
)
# Priority: development_repo > development_pr > integrated
if repo_path and dev_pr_number:
_LOGGER.warning(
"Both development_repo and development_pr are specified for frontend. "
"Using development_repo, remove development_repo to use "
"automatic PR download"
)
dev_pr_number = None
if dev_pr_number:
github_token: str = conf[CONF_GITHUB_TOKEN]
try:
dev_pr_dir = await download_pr_artifact(
hass, dev_pr_number, github_token, pr_cache_dir
)
repo_path = str(dev_pr_dir)
_LOGGER.info("Using frontend from PR #%s", dev_pr_number)
except HomeAssistantError as err:
_LOGGER.error(
"Failed to download PR #%s: %s, falling back to the integrated frontend",
dev_pr_number,
err,
)
except Exception: # pylint: disable=broad-exception-caught
_LOGGER.exception(
"Unexpected error downloading PR #%s, "
"falling back to the integrated frontend",
dev_pr_number,
)
is_dev = repo_path is not None
root_path = _frontend_root(repo_path)

View File

@@ -1,242 +0,0 @@
"""GitHub PR artifact download functionality for frontend development."""
from __future__ import annotations
import io
import logging
import pathlib
import shutil
import zipfile
from aiogithubapi import (
GitHubAPI,
GitHubAuthenticationException,
GitHubException,
GitHubNotFoundException,
GitHubPermissionException,
GitHubRatelimitException,
)
from aiohttp import ClientError, ClientResponseError, ClientTimeout
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
GITHUB_REPO = "home-assistant/frontend"
ARTIFACT_NAME = "frontend-build"
# Zip bomb protection limits (10x typical frontend build size)
# Typical frontend build: ~4500 files, ~135MB uncompressed
MAX_ZIP_FILES = 50000
MAX_ZIP_SIZE = 1500 * 1024 * 1024 # 1.5GB
ERROR_INVALID_TOKEN = (
"GitHub token is invalid or expired. "
"Please check your github_token in the frontend configuration. "
"Generate a new token at https://github.com/settings/tokens"
)
ERROR_RATE_LIMIT = (
"GitHub API rate limit exceeded or token lacks permissions. "
"Ensure your token has 'repo' or 'public_repo' scope"
)
async def _get_pr_head_sha(client: GitHubAPI, pr_number: int) -> str:
"""Get the head SHA for the PR."""
try:
response = await client.generic(
endpoint=f"/repos/home-assistant/frontend/pulls/{pr_number}",
)
return str(response.data["head"]["sha"])
except GitHubAuthenticationException as err:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
except (GitHubRatelimitException, GitHubPermissionException) as err:
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
except GitHubNotFoundException as err:
raise HomeAssistantError(
f"PR #{pr_number} does not exist in repository {GITHUB_REPO}"
) from err
except GitHubException as err:
raise HomeAssistantError(f"GitHub API error: {err}") from err
async def _find_pr_artifact(client: GitHubAPI, pr_number: int, head_sha: str) -> str:
"""Find the build artifact for the given PR and commit SHA.
Returns the artifact download URL.
"""
try:
response = await client.generic(
endpoint="/repos/home-assistant/frontend/actions/workflows/ci.yaml/runs",
params={"head_sha": head_sha, "per_page": 10},
)
for run in response.data.get("workflow_runs", []):
if run["status"] == "completed" and run["conclusion"] == "success":
artifacts_response = await client.generic(
endpoint=f"/repos/home-assistant/frontend/actions/runs/{run['id']}/artifacts",
)
for artifact in artifacts_response.data.get("artifacts", []):
if artifact["name"] == ARTIFACT_NAME:
_LOGGER.info(
"Found artifact '%s' from CI run #%s",
ARTIFACT_NAME,
run["id"],
)
return str(artifact["archive_download_url"])
raise HomeAssistantError(
f"No '{ARTIFACT_NAME}' artifact found for PR #{pr_number}. "
"Possible reasons: CI has not run yet or is running, "
"or the build failed, or the PR artifact expired. "
f"Check https://github.com/{GITHUB_REPO}/pull/{pr_number}/checks"
)
except GitHubAuthenticationException as err:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
except (GitHubRatelimitException, GitHubPermissionException) as err:
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
except GitHubException as err:
raise HomeAssistantError(f"GitHub API error: {err}") from err
async def _download_artifact_data(
hass: HomeAssistant, artifact_url: str, github_token: str
) -> bytes:
"""Download artifact data from GitHub."""
session = async_get_clientsession(hass)
headers = {
"Authorization": f"token {github_token}",
"Accept": "application/vnd.github+json",
}
try:
response = await session.get(
artifact_url, headers=headers, timeout=ClientTimeout(total=60)
)
response.raise_for_status()
return await response.read()
except ClientResponseError as err:
if err.status == 401:
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
if err.status == 403:
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
raise HomeAssistantError(
f"Failed to download artifact: HTTP {err.status}"
) from err
except TimeoutError as err:
raise HomeAssistantError(
"Timeout downloading artifact (>60s). Check your network connection"
) from err
except ClientError as err:
raise HomeAssistantError(f"Network error downloading artifact: {err}") from err
def _extract_artifact(
artifact_data: bytes,
cache_dir: pathlib.Path,
head_sha: str,
) -> None:
"""Extract artifact and save SHA (runs in executor)."""
frontend_dir = cache_dir / "hass_frontend"
if cache_dir.exists():
shutil.rmtree(cache_dir)
frontend_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(io.BytesIO(artifact_data)) as zip_file:
# Validate zip contents to protect against zip bombs
# See: https://github.com/python/cpython/issues/80643
total_size = 0
for file_count, info in enumerate(zip_file.infolist(), start=1):
total_size += info.file_size
if file_count > MAX_ZIP_FILES:
raise ValueError(
f"Zip contains too many files (>{MAX_ZIP_FILES}), possible zip bomb"
)
if total_size > MAX_ZIP_SIZE:
raise ValueError(
f"Zip uncompressed size too large (>{MAX_ZIP_SIZE} bytes), "
"possible zip bomb"
)
zip_file.extractall(str(frontend_dir))
# Save the commit SHA for cache validation
sha_file = cache_dir / ".sha"
sha_file.write_text(head_sha)
async def download_pr_artifact(
hass: HomeAssistant,
pr_number: int,
github_token: str,
tmp_dir: pathlib.Path,
) -> pathlib.Path:
"""Download and extract frontend PR artifact from GitHub.
Returns the path to the tmp directory containing hass_frontend/.
Raises HomeAssistantError on failure.
"""
try:
session = async_get_clientsession(hass)
except Exception as err:
raise HomeAssistantError(f"Failed to get HTTP client session: {err}") from err
client = GitHubAPI(token=github_token, session=session)
head_sha = await _get_pr_head_sha(client, pr_number)
frontend_dir = tmp_dir / "hass_frontend"
sha_file = tmp_dir / ".sha"
if frontend_dir.exists() and sha_file.exists():
try:
cached_sha = await hass.async_add_executor_job(sha_file.read_text)
if cached_sha.strip() == head_sha:
_LOGGER.info(
"Using cached PR #%s (commit %s) from %s",
pr_number,
head_sha[:8],
tmp_dir,
)
return tmp_dir
_LOGGER.info(
"PR #%s has new commits (cached: %s, current: %s), re-downloading",
pr_number,
cached_sha[:8],
head_sha[:8],
)
except OSError as err:
_LOGGER.debug("Failed to read cache SHA file: %s", err)
artifact_url = await _find_pr_artifact(client, pr_number, head_sha)
_LOGGER.info("Downloading frontend PR #%s artifact", pr_number)
artifact_data = await _download_artifact_data(hass, artifact_url, github_token)
try:
await hass.async_add_executor_job(
_extract_artifact, artifact_data, tmp_dir, head_sha
)
except zipfile.BadZipFile as err:
raise HomeAssistantError(
f"Downloaded artifact for PR #{pr_number} is corrupted or invalid"
) from err
except ValueError as err:
raise HomeAssistantError(
f"Downloaded artifact for PR #{pr_number} failed validation: {err}"
) from err
except OSError as err:
raise HomeAssistantError(
f"Failed to extract artifact for PR #{pr_number}: {err}"
) from err
_LOGGER.info(
"Successfully downloaded and extracted PR #%s (commit %s) to %s",
pr_number,
head_sha[:8],
tmp_dir,
)
return tmp_dir

View File

@@ -13,7 +13,10 @@ from homeassistant.helpers import (
discovery,
entity_registry as er,
)
from homeassistant.helpers.device import async_entity_id_to_device_id
from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.event import async_track_entity_registry_updated_event
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
@@ -93,6 +96,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry."""
# This can be removed in HA Core 2026.2
async_remove_stale_devices_links_keep_entity_device(
hass,
entry.entry_id,
entry.options[CONF_HUMIDIFIER],
)
def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None:
hass.config_entries.async_update_entry(
entry,

View File

@@ -5,7 +5,10 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device import async_entity_id_to_device_id
from homeassistant.helpers.device import (
async_entity_id_to_device_id,
async_remove_stale_devices_links_keep_entity_device,
)
from homeassistant.helpers.event import async_track_entity_registry_updated_event
from homeassistant.helpers.helper_integration import (
async_handle_source_entity_changes,
@@ -20,6 +23,13 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry."""
# This can be removed in HA Core 2026.2
async_remove_stale_devices_links_keep_entity_device(
hass,
entry.entry_id,
entry.options[CONF_HEATER],
)
def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None:
hass.config_entries.async_update_entry(
entry,

Some files were not shown because too many files have changed in this diff Show More