Compare commits

...

77 Commits

Author SHA1 Message Date
Robert Resch
bf920280a3 test 2025-11-11 13:11:25 +01:00
Robert Resch
8796997a0a Pin go2rtc to sha256 hash 2025-11-11 13:08:01 +01:00
Erik Montnemery
93025c9845 Use pytest.mark.freeze_time in pglab tests (#156346) 2025-11-11 13:05:17 +01:00
Erik Montnemery
df348644b1 Use pytest.mark.freeze_time in openai_conversation tests (#156345) 2025-11-11 13:05:02 +01:00
Erik Montnemery
8749b0d750 Use pytest.mark.freeze_time in smhi tests (#156352) 2025-11-11 13:02:21 +01:00
Erik Montnemery
a6a1519c06 Use pytest.mark.freeze_time in snoo tests (#156353) 2025-11-11 13:02:01 +01:00
Erik Montnemery
3068e19843 Use pytest.mark.freeze_time in telegram_bot tests (#156354) 2025-11-11 13:01:34 +01:00
Erik Montnemery
55feb1e735 Use pytest.mark.freeze_time in tomorrowio tests (#156355) 2025-11-11 13:01:29 +01:00
Erik Montnemery
bb7dc69131 Use pytest.mark.freeze_time in yale_smart_alarm tests (#156359) 2025-11-11 12:06:22 +01:00
Erik Montnemery
aa9003a524 Use pytest.mark.freeze_time in wake_word tests (#156360) 2025-11-11 12:06:12 +01:00
Erik Montnemery
4e9da5249d Use pytest.mark.freeze_time in utility_meter tests (#156361) 2025-11-11 12:05:58 +01:00
Erik Montnemery
f502739df2 Use pytest.mark.freeze_time in zha tests (#156358) 2025-11-11 12:04:59 +01:00
Erik Montnemery
0f2ff29378 Use pytest.mark.freeze_time in sleep_as_android tests (#156351) 2025-11-11 12:04:40 +01:00
Erik Montnemery
2921e7ed3c Use pytest.mark.freeze_time in plaato tests (#156362) 2025-11-11 12:04:31 +01:00
Christopher Fenner
25d44e8d37 Enhance compressor phase with state translations in ViCare integration (#156238) 2025-11-11 11:20:27 +01:00
Will Moss
0a480a26a3 Remove import of config_entry_oauth2_flow in scaffold in favor of direct imports (#156302) 2025-11-11 11:17:31 +01:00
Khole
d5da64dd8d Bump pyhive to 1.0.7 (#156309) 2025-11-11 11:16:11 +01:00
wollew
92adcd8635 add the velux KLF 200 gateway as device (#155434)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-11 11:13:18 +01:00
Joost Lekkerkerker
ee0c4b15c2 Make certain fields required for subentry flows (#156251) 2025-11-11 09:42:51 +01:00
Erik Montnemery
507f54198e Use pytest.mark.freeze_time in habitica tests (#156332) 2025-11-11 09:37:17 +01:00
epenet
0ed342b433 Use dpcode_wrapper in tuya alarm control panel platform (#156306) 2025-11-11 09:36:09 +01:00
cdnninja
363c86faf3 Add remove entity to vesync (#156213) 2025-11-11 09:35:19 +01:00
dependabot[bot]
095a7ad060 Bump actions/dependency-review-action from 4.8.1 to 4.8.2 (#156322)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-11 09:34:38 +01:00
Åke Strandberg
ab5981bbbd Use common string for OAuth2 implementation error in myuplink (#156338) 2025-11-11 09:33:59 +01:00
Erik Montnemery
ac2fb53dfd Fix typo in recorder statistics_meta table manager (#156326) 2025-11-11 09:33:30 +01:00
Erik Montnemery
02ff5de1ff Use pytest.mark.freeze_time in ntfy tests (#156336) 2025-11-11 09:33:21 +01:00
Erik Montnemery
5cd5d480d9 Check collation of statistics_meta DB table (#156327) 2025-11-11 09:31:43 +01:00
Erik Montnemery
a3c7d772fc Use pytest.mark.freeze_time in conversation tests (#156329) 2025-11-11 09:29:46 +01:00
micha91
fe0c69dba7 Update aiomusiccast to 0.15 (#156325) 2025-11-11 09:26:16 +01:00
Artur Pragacz
e5365234c3 Add myself as codeowner to music assistant (#156324) 2025-11-11 09:24:09 +01:00
Erik Montnemery
1531175bd3 Use pytest.mark.freeze_time in google tests (#156330) 2025-11-11 09:22:48 +01:00
Erik Montnemery
62add59ff4 Use pytest.mark.freeze_time in google_generative_ai_conversation tests (#156331) 2025-11-11 09:21:52 +01:00
Erik Montnemery
d8daca657b Use pytest.mark.freeze_time in intellifire tests (#156333) 2025-11-11 10:17:58 +02:00
Erik Montnemery
1891da46ea Use pytest.mark.freeze_time in knx tests (#156335) 2025-11-11 08:52:39 +01:00
Marc Mueller
22ae894745 Update pytest-asyncio to 1.3.0 (#156315) 2025-11-10 22:07:02 -08:00
Will Moss
160810c69d Move oauth2_implementation_unavailable string to top level (#156299) 2025-11-11 06:58:24 +01:00
epenet
2ae23b920a Use dpcode_wrapper in tuya siren platform (#156284) 2025-11-10 23:06:14 +01:00
Artur Pragacz
a7edfb082f Move config intents to manager (#154903) 2025-11-10 16:04:25 -06:00
Ludovic BOUÉ
3ac203b05f Add Matter Aqara W100 fixture (#156305)
- Adds JSON fixture file containing Matter node data for the Aqara W100 sensor
- Updates test configuration to include the new fixture in parametrized tests
- Adds snapshot test data for sensor and button entities created by this device
2025-11-10 21:58:18 +01:00
Jan Bouwhuis
7c3eb19fc4 Fix issues() template method returns non active issues (#156274) 2025-11-10 21:56:57 +01:00
kingy444
70c6fac743 Move hunterdouglas_powerview data class to upstream library (#156228)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-10 14:49:00 -06:00
Åke Strandberg
e19d7250d5 Adjust user-facing string for miele (#156280) 2025-11-10 20:42:42 +01:00
Maikel Punie
a850d5dba7 Bump velbusaio to 2025.11.0 (#156293) 2025-11-10 21:25:00 +02:00
Erik Montnemery
0cf0f10654 Correct migration to recorder schema 51 (#156267)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-10 20:14:25 +01:00
Ludovic BOUÉ
8429f154ca Fix status checks in Matter binary sensors (#156276)
This PR fixes bitmap bit checking logic in Matter binary sensors by replacing equality comparisons with bitwise AND operations. The changes correct how the integration checks if specific bits are set in bitmap fields.

Key changes:

Changed equality checks (==) to bitwise AND operations (&) for checking bitmap bits
Wrapped bitwise operations with bool() to ensure boolean return values
Applied fixes consistently across PumpStatus, DishwasherAlarm, and RefrigeratorAlarm bitmaps
2025-11-10 19:45:17 +01:00
Assaf Inbal
7b4f5ad362 Ituran: Don't cache properties (#156281) 2025-11-10 19:24:58 +02:00
David Rapan
583b439557 Add Shelly number translation (#156156)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-10 19:15:16 +02:00
Michael Hansen
05922de102 Always chunk Wyoming TTS audio (#156079) 2025-11-10 10:40:45 -05:00
Khole
7675a44b90 Hive: Remove Alarm Support (#156184) 2025-11-10 16:32:38 +01:00
Simone Chemelli
1e4d645683 Fix config flow reconfigure for Comelit (#156193) 2025-11-10 16:28:47 +01:00
Glenn Vandeuren (aka Iondependent)
b5ae04605a Add climate platform for niko_home_control (#138087)
Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: VandeurenGlenn <8685280+VandeurenGlenn@users.noreply.github.com>
2025-11-10 16:27:59 +01:00
Manu
2240d6b94c Enable trophy sensors also for friends in PlayStation Network integration (#156106) 2025-11-10 16:18:15 +01:00
Foscam-wangzhengyu
d1536ee636 Foscam Integration with Legacy Model Compatibility (#156226)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 16:14:57 +01:00
J. Nick Koston
8a926add7a Bump PySwitchbot to 0.73.0 (#156266) 2025-11-10 10:10:23 -05:00
J. Nick Koston
31f769900a Bump aiopvapi to 3.3.0 (#156268) 2025-11-10 10:06:58 -05:00
cdnninja
33ad777664 Add temp sensor to vesync humidifers (#155637)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 16:03:16 +01:00
Bouwe Westerdijk
59a4e4a337 Add Plugwise Adam zone profile select (#156262) 2025-11-10 16:00:26 +01:00
Andre Lengwenus
66a39933b0 Remove translations for non-existing service (#156265) 2025-11-10 15:53:00 +01:00
Heindrich Paul
ad395e3bba Add delay clean time support to Tuya integration for cat litter boxes (#156053) 2025-11-10 15:48:00 +01:00
hanwg
cfc6f2c229 Remove yaml in tests for Telegram polling bot (#156257) 2025-11-10 15:30:06 +01:00
Andrew Jackson
63aa41c766 Bump aiomealie to 1.1.0, adding recipe rating (#156256) 2025-11-10 15:28:45 +01:00
Tom Matheussen
037e0e93d3 Cleanup binary sensor platform for Satel Integra (#155915)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 15:13:49 +01:00
epenet
db8b5865b3 Improve Tuya event tests (#156259) 2025-11-10 15:03:23 +01:00
epenet
bd2ccc6672 Add tests for tuya button (#156252) 2025-11-10 14:54:51 +01:00
Joost Lekkerkerker
bb63d40cdf Bump pySmartThings to 3.3.2 (#156250) 2025-11-10 14:53:29 +01:00
Ludovic BOUÉ
65285b8885 Fix Matter ValveFault attribute handling (#156258)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-10 14:45:13 +01:00
Denis Shulyaka
326b8f2b4f Add AI task for Anthropic (#156221) 2025-11-10 14:01:28 +01:00
Heindrich Paul
9f3df52fcc Added light support to cat litter boxes (#156051) 2025-11-10 13:57:54 +01:00
wollew
875838c277 adjust naming of velux light entities according to guidelines (#155850) 2025-11-10 13:55:17 +01:00
epenet
adaafd1fda Use dpcode_wrapper in tuya binary sensor platform (#156247)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-10 13:54:09 +01:00
Heindrich Paul
50c5efddaa Add buttons for cat litter box devices (#156050) 2025-11-10 13:50:40 +01:00
epenet
c4be054161 Adjust Tuya DPCodeBooleanWrapper inheritance (#156255) 2025-11-10 13:39:09 +01:00
Bouwe Westerdijk
61186356f3 Refresh test-fixtures for Plugwise (#156253) 2025-11-10 13:35:24 +01:00
Will Moss
9d60a19440 Improved error handling for oauth2 configuration in volvo integration (#156215) 2025-11-10 13:17:48 +01:00
epenet
108c212855 Use dpcode_wrapper in tuya button platform (#156237) 2025-11-10 12:58:42 +01:00
Erik Montnemery
ae8db81c4e Use pytest.mark.freeze_time in ambient_network tests (#156241) 2025-11-10 12:50:43 +01:00
dotvav
51c970d1d0 Bump pypalazzetti lib from 0.1.19 to 0.1.20 (#156249) 2025-11-10 12:49:40 +01:00
184 changed files with 8570 additions and 5644 deletions

View File

@@ -46,10 +46,10 @@ jobs:
with: with:
type: ${{ env.BUILD_TYPE }} type: ${{ env.BUILD_TYPE }}
- name: Verify version # - name: Verify version
uses: home-assistant/actions/helpers/verify-version@master # uses: home-assistant/actions/helpers/verify-version@master
with: # with:
ignore-dev: true # ignore-dev: true
- name: Fail if translations files are checked in - name: Fail if translations files are checked in
run: | run: |
@@ -88,10 +88,6 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
exclude:
- arch: armv7
- arch: armhf
- arch: i386
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
@@ -166,6 +162,18 @@ jobs:
sed -i "s|home-assistant-intents==.*||" requirements_all.txt sed -i "s|home-assistant-intents==.*||" requirements_all.txt
fi fi
- name: Adjustments for armhf
if: matrix.arch == 'armhf'
run: |
# Pandas has issues building on armhf, it is expected they
# will drop the platform in the near future (they consider it
# "flimsy" on 386). The following packages depend on pandas,
# so we comment them out.
sed -i "s|env-canada|# env-canada|g" requirements_all.txt
sed -i "s|noaa-coops|# noaa-coops|g" requirements_all.txt
sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations - name: Download translations
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
@@ -199,254 +207,284 @@ jobs:
--target /data \ --target /data \
--generic ${{ needs.init.outputs.version }} --generic ${{ needs.init.outputs.version }}
build_machine: # build_machine:
name: Build ${{ matrix.machine }} machine core image # name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant' # if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"] # needs: ["init", "build_base"]
runs-on: ubuntu-latest # runs-on: ubuntu-latest
permissions: # permissions:
contents: read # contents: read
packages: write # packages: write
id-token: write # id-token: write
strategy: # strategy:
matrix: # matrix:
machine: # machine:
- generic-x86-64 # - generic-x86-64
- intel-nuc # - intel-nuc
- khadas-vim3 # - khadas-vim3
- odroid-c2 # - odroid-c2
- odroid-c4 # - odroid-c4
- odroid-m1 # - odroid-m1
- odroid-n2 # - odroid-n2
- qemuarm-64 # - odroid-xu
- qemux86-64 # - qemuarm
- raspberrypi3-64 # - qemuarm-64
- raspberrypi4-64 # - qemux86
- raspberrypi5-64 # - qemux86-64
- yellow # - raspberrypi
- green # - raspberrypi2
steps: # - raspberrypi3
- name: Checkout the repository # - raspberrypi3-64
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # - raspberrypi4
# - raspberrypi4-64
# - raspberrypi5-64
# - tinker
# - yellow
# - green
# steps:
# - name: Checkout the repository
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set build additional args # - name: Set build additional args
run: | # run: |
# Create general tags # # Create general tags
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then # if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV # echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then # elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV # echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
else # else
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV # echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
fi # fi
- name: Login to GitHub Container Registry # - name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 # uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: # with:
registry: ghcr.io # registry: ghcr.io
username: ${{ github.repository_owner }} # username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} # password: ${{ secrets.GITHUB_TOKEN }}
# home-assistant/builder doesn't support sha pinning # # home-assistant/builder doesn't support sha pinning
- name: Build base image # - name: Build base image
uses: home-assistant/builder@2025.09.0 # uses: home-assistant/builder@2025.09.0
with: # with:
args: | # args: |
$BUILD_ARGS \ # $BUILD_ARGS \
--target /data/machine \ # --target /data/machine \
--cosign \ # --cosign \
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}" # --machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
publish_ha: # publish_ha:
name: Publish version files # name: Publish version files
environment: ${{ needs.init.outputs.channel }} # environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant' # if: github.repository_owner == 'home-assistant'
needs: ["init", "build_machine"] # needs: ["init", "build_machine"]
runs-on: ubuntu-latest # runs-on: ubuntu-latest
steps: # steps:
- name: Checkout the repository # - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize git # - name: Initialize git
uses: home-assistant/actions/helpers/git-init@master # uses: home-assistant/actions/helpers/git-init@master
with: # with:
name: ${{ secrets.GIT_NAME }} # name: ${{ secrets.GIT_NAME }}
email: ${{ secrets.GIT_EMAIL }} # email: ${{ secrets.GIT_EMAIL }}
token: ${{ secrets.GIT_TOKEN }} # token: ${{ secrets.GIT_TOKEN }}
- name: Update version file # - name: Update version file
uses: home-assistant/actions/helpers/version-push@master # uses: home-assistant/actions/helpers/version-push@master
with: # with:
key: "homeassistant[]" # key: "homeassistant[]"
key-description: "Home Assistant Core" # key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }} # version: ${{ needs.init.outputs.version }}
channel: ${{ needs.init.outputs.channel }} # channel: ${{ needs.init.outputs.channel }}
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
- name: Update version file (stable -> beta) # - name: Update version file (stable -> beta)
if: needs.init.outputs.channel == 'stable' # if: needs.init.outputs.channel == 'stable'
uses: home-assistant/actions/helpers/version-push@master # uses: home-assistant/actions/helpers/version-push@master
with: # with:
key: "homeassistant[]" # key: "homeassistant[]"
key-description: "Home Assistant Core" # key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }} # version: ${{ needs.init.outputs.version }}
channel: beta # channel: beta
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container: # publish_container:
name: Publish meta container for ${{ matrix.registry }} # name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }} # environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant' # if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"] # needs: ["init", "build_base"]
runs-on: ubuntu-latest # runs-on: ubuntu-latest
permissions: # permissions:
contents: read # contents: read
packages: write # packages: write
id-token: write # id-token: write
strategy: # strategy:
fail-fast: false # fail-fast: false
matrix: # matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] # registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps: # steps:
- name: Checkout the repository # - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 # uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install Cosign # - name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 # uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with: # with:
cosign-release: "v2.2.3" # cosign-release: "v2.2.3"
- name: Login to DockerHub # - name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant' # if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 # uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: # with:
username: ${{ secrets.DOCKERHUB_USERNAME }} # username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} # password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry # - name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant' # if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 # uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: # with:
registry: ghcr.io # registry: ghcr.io
username: ${{ github.repository_owner }} # username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} # password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Meta Image # - name: Build Meta Image
shell: bash # shell: bash
run: | # run: |
export DOCKER_CLI_EXPERIMENTAL=enabled # export DOCKER_CLI_EXPERIMENTAL=enabled
function create_manifest() { # function create_manifest() {
local tag_l=${1} # local tag_l=${1}
local tag_r=${2} # local tag_r=${2}
local registry=${{ matrix.registry }} # local registry=${{ matrix.registry }}
docker manifest create "${registry}/home-assistant:${tag_l}" \ # docker manifest create "${registry}/home-assistant:${tag_l}" \
"${registry}/amd64-homeassistant:${tag_r}" \ # "${registry}/amd64-homeassistant:${tag_r}" \
"${registry}/aarch64-homeassistant:${tag_r}" # "${registry}/i386-homeassistant:${tag_r}" \
# "${registry}/armhf-homeassistant:${tag_r}" \
# "${registry}/armv7-homeassistant:${tag_r}" \
# "${registry}/aarch64-homeassistant:${tag_r}"
docker manifest annotate "${registry}/home-assistant:${tag_l}" \ # docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/amd64-homeassistant:${tag_r}" \ # "${registry}/amd64-homeassistant:${tag_r}" \
--os linux --arch amd64 # --os linux --arch amd64
docker manifest annotate "${registry}/home-assistant:${tag_l}" \ # docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/aarch64-homeassistant:${tag_r}" \ # "${registry}/i386-homeassistant:${tag_r}" \
--os linux --arch arm64 --variant=v8 # --os linux --arch 386
docker manifest push --purge "${registry}/home-assistant:${tag_l}" # docker manifest annotate "${registry}/home-assistant:${tag_l}" \
cosign sign --yes "${registry}/home-assistant:${tag_l}" # "${registry}/armhf-homeassistant:${tag_r}" \
} # --os linux --arch arm --variant=v6
function validate_image() { # docker manifest annotate "${registry}/home-assistant:${tag_l}" \
local image=${1} # "${registry}/armv7-homeassistant:${tag_r}" \
if ! cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp https://github.com/home-assistant/core/.* "${image}"; then # --os linux --arch arm --variant=v7
echo "Invalid signature!"
exit 1
fi
}
function push_dockerhub() { # docker manifest annotate "${registry}/home-assistant:${tag_l}" \
local image=${1} # "${registry}/aarch64-homeassistant:${tag_r}" \
local tag=${2} # --os linux --arch arm64 --variant=v8
docker tag "ghcr.io/home-assistant/${image}:${tag}" "docker.io/homeassistant/${image}:${tag}" # docker manifest push --purge "${registry}/home-assistant:${tag_l}"
docker push "docker.io/homeassistant/${image}:${tag}" # cosign sign --yes "${registry}/home-assistant:${tag_l}"
cosign sign --yes "docker.io/homeassistant/${image}:${tag}" # }
}
# Pull images from github container registry and verify signature # function validate_image() {
docker pull "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}" # local image=${1}
docker pull "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}" # if ! cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp https://github.com/home-assistant/core/.* "${image}"; then
# echo "Invalid signature!"
# exit 1
# fi
# }
validate_image "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}" # function push_dockerhub() {
validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}" # local image=${1}
# local tag=${2}
if [[ "${{ matrix.registry }}" == "docker.io/homeassistant" ]]; then # docker tag "ghcr.io/home-assistant/${image}:${tag}" "docker.io/homeassistant/${image}:${tag}"
# Upload images to dockerhub # docker push "docker.io/homeassistant/${image}:${tag}"
push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}" # cosign sign --yes "docker.io/homeassistant/${image}:${tag}"
push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}" # }
fi
# Create version tag # # Pull images from github container registry and verify signature
create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}" # docker pull "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}"
# docker pull "ghcr.io/home-assistant/i386-homeassistant:${{ needs.init.outputs.version }}"
# docker pull "ghcr.io/home-assistant/armhf-homeassistant:${{ needs.init.outputs.version }}"
# docker pull "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}"
# docker pull "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}"
# Create general tags # validate_image "ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }}"
if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then # validate_image "ghcr.io/home-assistant/i386-homeassistant:${{ needs.init.outputs.version }}"
create_manifest "dev" "${{ needs.init.outputs.version }}" # validate_image "ghcr.io/home-assistant/armhf-homeassistant:${{ needs.init.outputs.version }}"
elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then # validate_image "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}"
create_manifest "beta" "${{ needs.init.outputs.version }}" # validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}"
create_manifest "rc" "${{ needs.init.outputs.version }}"
else
create_manifest "stable" "${{ needs.init.outputs.version }}"
create_manifest "latest" "${{ needs.init.outputs.version }}"
create_manifest "beta" "${{ needs.init.outputs.version }}"
create_manifest "rc" "${{ needs.init.outputs.version }}"
# Create series version tag (e.g. 2021.6) # if [[ "${{ matrix.registry }}" == "docker.io/homeassistant" ]]; then
v="${{ needs.init.outputs.version }}" # # Upload images to dockerhub
create_manifest "${v%.*}" "${{ needs.init.outputs.version }}" # push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}"
fi # push_dockerhub "i386-homeassistant" "${{ needs.init.outputs.version }}"
# push_dockerhub "armhf-homeassistant" "${{ needs.init.outputs.version }}"
# push_dockerhub "armv7-homeassistant" "${{ needs.init.outputs.version }}"
# push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}"
# fi
build_python: # # Create version tag
name: Build PyPi package # create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} # # Create general tags
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 # if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
with: # create_manifest "dev" "${{ needs.init.outputs.version }}"
python-version: ${{ env.DEFAULT_PYTHON }} # elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
# create_manifest "beta" "${{ needs.init.outputs.version }}"
# create_manifest "rc" "${{ needs.init.outputs.version }}"
# else
# create_manifest "stable" "${{ needs.init.outputs.version }}"
# create_manifest "latest" "${{ needs.init.outputs.version }}"
# create_manifest "beta" "${{ needs.init.outputs.version }}"
# create_manifest "rc" "${{ needs.init.outputs.version }}"
- name: Download translations # # Create series version tag (e.g. 2021.6)
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 # v="${{ needs.init.outputs.version }}"
with: # create_manifest "${v%.*}" "${{ needs.init.outputs.version }}"
name: translations # fi
- name: Extract translations # build_python:
run: | # name: Build PyPi package
tar xvf translations.tar.gz # environment: ${{ needs.init.outputs.channel }}
rm translations.tar.gz # needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# permissions:
# contents: read
# id-token: write
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
# steps:
# - name: Checkout the repository
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Build package # - name: Set up Python ${{ env.DEFAULT_PYTHON }}
shell: bash # uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
run: | # with:
# Remove dist, build, and homeassistant.egg-info # python-version: ${{ env.DEFAULT_PYTHON }}
# when build locally for testing!
pip install build
python -m build
- name: Upload package to PyPI # - name: Download translations
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 # uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: # with:
skip-existing: true # name: translations
# - name: Extract translations
# run: |
# tar xvf translations.tar.gz
# rm translations.tar.gz
# - name: Build package
# shell: bash
# run: |
# # Remove dist, build, and homeassistant.egg-info
# # when build locally for testing!
# pip install build
# python -m build
# - name: Upload package to PyPI
# uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
# with:
# skip-existing: true
hassfest-image: hassfest-image:
name: Build and test hassfest image name: Build and test hassfest image
@@ -493,10 +531,10 @@ jobs:
push: true push: true
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
- name: Generate artifact attestation # - name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' # if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 # uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with: # with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }} # subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }} # subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true # push-to-registry: true

View File

@@ -622,7 +622,7 @@ jobs:
steps: steps:
- *checkout - *checkout
- name: Dependency review - name: Dependency review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1 uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with: with:
license-check: false # We use our own license audit checks license-check: false # We use our own license audit checks

View File

@@ -94,7 +94,7 @@ repos:
pass_filenames: false pass_filenames: false
language: script language: script
types: [text] types: [text]
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$ files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$
- id: hassfest-mypy-config - id: hassfest-mypy-config
name: hassfest-mypy-config name: hassfest-mypy-config
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config

4
CODEOWNERS generated
View File

@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/msteams/ @peroyvind /homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mullvad/ @meichthys /homeassistant/components/mullvad/ @meichthys
/tests/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys
/homeassistant/components/music_assistant/ @music-assistant /homeassistant/components/music_assistant/ @music-assistant @arturpragacz
/tests/components/music_assistant/ @music-assistant /tests/components/music_assistant/ @music-assistant @arturpragacz
/homeassistant/components/mutesync/ @currentoor /homeassistant/components/mutesync/ @currentoor
/tests/components/mutesync/ @currentoor /tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core /homeassistant/components/my/ @home-assistant/core

16
Dockerfile generated
View File

@@ -15,20 +15,10 @@ ARG QEMU_CPU
# Home Assistant S6-Overlay # Home Assistant S6-Overlay
COPY rootfs / COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary # Get go2rtc binary
RUN \ COPY --from=ghcr.io/alexxit/go2rtc@sha256:64ab39fdcf7571075f4ef1a818a1019aa359e2e730600771d265b50a94449532 /usr/local/bin/go2rtc /bin/go2rtc
case "${BUILD_ARCH}" in \ # Verify go2rtc can be executed
"aarch64") go2rtc_suffix='arm64' ;; \ RUN go2rtc --version
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
# Install uv # Install uv
RUN pip3 install uv==0.9.6 RUN pip3 install uv==0.9.6

View File

@@ -25,7 +25,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL, RECOMMENDED_CHAT_MODEL,
) )
PLATFORMS = (Platform.CONVERSATION,) PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient] type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]

View File

@@ -0,0 +1,80 @@
"""AI Task integration for Anthropic."""
from __future__ import annotations
from json import JSONDecodeError
import logging
from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .entity import AnthropicBaseLLMEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "ai_task_data":
continue
async_add_entities(
[AnthropicTaskEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class AnthropicTaskEntity(
ai_task.AITaskEntity,
AnthropicBaseLLMEntity,
):
"""Anthropic AI Task entity."""
_attr_supported_features = (
ai_task.AITaskEntityFeature.GENERATE_DATA
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
)
async def _async_generate_data(
self,
task: ai_task.GenDataTask,
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(chat_log, task.name, task.structure)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
"Last content in chat log is not an AssistantContent"
)
text = chat_log.content[-1].content or ""
if not task.structure:
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=text,
)
try:
data = json_loads(text)
except JSONDecodeError as err:
_LOGGER.error(
"Failed to parse JSON response: %s. Response: %s",
err,
text,
)
raise HomeAssistantError("Error with Claude structured response") from err
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=data,
)

View File

@@ -53,6 +53,7 @@ from .const import (
CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION, CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME, DEFAULT_CONVERSATION_NAME,
DOMAIN, DOMAIN,
NON_THINKING_MODELS, NON_THINKING_MODELS,
@@ -74,12 +75,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
} }
) )
RECOMMENDED_OPTIONS = { RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_RECOMMENDED: True, CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
} }
RECOMMENDED_AI_TASK_OPTIONS = {
CONF_RECOMMENDED: True,
}
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect. """Validate the user input allows us to connect.
@@ -102,7 +107,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
errors = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self._async_abort_entries_match(user_input) self._async_abort_entries_match(user_input)
@@ -130,10 +135,16 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
subentries=[ subentries=[
{ {
"subentry_type": "conversation", "subentry_type": "conversation",
"data": RECOMMENDED_OPTIONS, "data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME, "title": DEFAULT_CONVERSATION_NAME,
"unique_id": None, "unique_id": None,
} },
{
"subentry_type": "ai_task_data",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
], ],
) )
@@ -147,7 +158,10 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]: ) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration.""" """Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler} return {
"conversation": ConversationSubentryFlowHandler,
"ai_task_data": ConversationSubentryFlowHandler,
}
class ConversationSubentryFlowHandler(ConfigSubentryFlow): class ConversationSubentryFlowHandler(ConfigSubentryFlow):
@@ -164,7 +178,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult: ) -> SubentryFlowResult:
"""Add a subentry.""" """Add a subentry."""
self.options = RECOMMENDED_OPTIONS.copy() if self._subentry_type == "ai_task_data":
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
else:
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
return await self.async_step_init() return await self.async_step_init()
async def async_step_reconfigure( async def async_step_reconfigure(
@@ -198,10 +215,13 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
errors: dict[str, str] = {} errors: dict[str, str] = {}
if self._is_new: if self._is_new:
step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = ( if self._subentry_type == "ai_task_data":
str default_name = DEFAULT_AI_TASK_NAME
) else:
default_name = DEFAULT_CONVERSATION_NAME
step_schema[vol.Required(CONF_NAME, default=default_name)] = str
if self._subentry_type == "conversation":
step_schema.update( step_schema.update(
{ {
vol.Optional(CONF_PROMPT): TemplateSelector(), vol.Optional(CONF_PROMPT): TemplateSelector(),
@@ -210,12 +230,15 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
): SelectSelector( ): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True) SelectSelectorConfig(options=hass_apis, multiple=True)
), ),
vol.Required(
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
): bool,
} }
) )
step_schema[
vol.Required(
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
)
] = bool
if user_input is not None: if user_input is not None:
if not user_input.get(CONF_LLM_HASS_API): if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None) user_input.pop(CONF_LLM_HASS_API, None)
@@ -298,10 +321,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if not model.startswith(tuple(NON_THINKING_MODELS)): if not model.startswith(tuple(NON_THINKING_MODELS)):
step_schema[ step_schema[
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET) vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
] = NumberSelector( ] = vol.All(
NumberSelector(
NumberSelectorConfig( NumberSelectorConfig(
min=0, max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS) min=0,
max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
) )
),
vol.Coerce(int),
) )
else: else:
self.options.pop(CONF_THINKING_BUDGET, None) self.options.pop(CONF_THINKING_BUDGET, None)

View File

@@ -6,6 +6,7 @@ DOMAIN = "anthropic"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
DEFAULT_CONVERSATION_NAME = "Claude conversation" DEFAULT_CONVERSATION_NAME = "Claude conversation"
DEFAULT_AI_TASK_NAME = "Claude AI Task"
CONF_RECOMMENDED = "recommended" CONF_RECOMMENDED = "recommended"
CONF_PROMPT = "prompt" CONF_PROMPT = "prompt"

View File

@@ -1,17 +1,24 @@
"""Base entity for Anthropic.""" """Base entity for Anthropic."""
import base64
from collections.abc import AsyncGenerator, Callable, Iterable from collections.abc import AsyncGenerator, Callable, Iterable
from dataclasses import dataclass, field from dataclasses import dataclass, field
import json import json
from mimetypes import guess_file_type
from pathlib import Path
from typing import Any from typing import Any
import anthropic import anthropic
from anthropic import AsyncStream from anthropic import AsyncStream
from anthropic.types import ( from anthropic.types import (
Base64ImageSourceParam,
Base64PDFSourceParam,
CitationsDelta, CitationsDelta,
CitationsWebSearchResultLocation, CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam, CitationWebSearchResultLocationParam,
ContentBlockParam, ContentBlockParam,
DocumentBlockParam,
ImageBlockParam,
InputJSONDelta, InputJSONDelta,
MessageDeltaUsage, MessageDeltaUsage,
MessageParam, MessageParam,
@@ -37,6 +44,9 @@ from anthropic.types import (
ThinkingConfigDisabledParam, ThinkingConfigDisabledParam,
ThinkingConfigEnabledParam, ThinkingConfigEnabledParam,
ThinkingDelta, ThinkingDelta,
ToolChoiceAnyParam,
ToolChoiceAutoParam,
ToolChoiceToolParam,
ToolParam, ToolParam,
ToolResultBlockParam, ToolResultBlockParam,
ToolUnionParam, ToolUnionParam,
@@ -50,13 +60,16 @@ from anthropic.types import (
WebSearchToolResultError, WebSearchToolResultError,
) )
from anthropic.types.message_create_params import MessageCreateParamsStreaming from anthropic.types.message_create_params import MessageCreateParamsStreaming
import voluptuous as vol
from voluptuous_openapi import convert from voluptuous_openapi import convert
from homeassistant.components import conversation from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
from . import AnthropicConfigEntry from . import AnthropicConfigEntry
from .const import ( from .const import (
@@ -321,6 +334,7 @@ def _convert_content(
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog, chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent], stream: AsyncStream[MessageStreamEvent],
output_tool: str | None = None,
) -> AsyncGenerator[ ) -> AsyncGenerator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]: ]:
@@ -381,6 +395,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
input="", input="",
) )
current_tool_args = "" current_tool_args = ""
if response.content_block.name == output_tool:
if first_block or content_details.has_content():
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
first_block = False
elif isinstance(response.content_block, TextBlock): elif isinstance(response.content_block, TextBlock):
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead. if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
first_block first_block
@@ -471,6 +495,15 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
first_block = True first_block = True
elif isinstance(response, RawContentBlockDeltaEvent): elif isinstance(response, RawContentBlockDeltaEvent):
if isinstance(response.delta, InputJSONDelta): if isinstance(response.delta, InputJSONDelta):
if (
current_tool_block is not None
and current_tool_block["name"] == output_tool
):
content_details.citation_details[-1].length += len(
response.delta.partial_json
)
yield {"content": response.delta.partial_json}
else:
current_tool_args += response.delta.partial_json current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta): elif isinstance(response.delta, TextDelta):
content_details.citation_details[-1].length += len(response.delta.text) content_details.citation_details[-1].length += len(response.delta.text)
@@ -490,6 +523,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
content_details.add_citation(response.delta.citation) content_details.add_citation(response.delta.citation)
elif isinstance(response, RawContentBlockStopEvent): elif isinstance(response, RawContentBlockStopEvent):
if current_tool_block is not None: if current_tool_block is not None:
if current_tool_block["name"] == output_tool:
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {} tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] = tool_args current_tool_block["input"] = tool_args
yield { yield {
@@ -557,6 +593,8 @@ class AnthropicBaseLLMEntity(Entity):
async def _async_handle_chat_log( async def _async_handle_chat_log(
self, self,
chat_log: conversation.ChatLog, chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
) -> None: ) -> None:
"""Generate an answer for the chat log.""" """Generate an answer for the chat log."""
options = self.subentry.data options = self.subentry.data
@@ -613,6 +651,74 @@ class AnthropicBaseLLMEntity(Entity):
} }
tools.append(web_search) tools.append(web_search)
# Handle attachments by adding them to the last user message
last_content = chat_log.content[-1]
if last_content.role == "user" and last_content.attachments:
last_message = messages[-1]
if last_message["role"] != "user":
raise HomeAssistantError(
"Last message must be a user message to add attachments"
)
if isinstance(last_message["content"], str):
last_message["content"] = [
TextBlockParam(type="text", text=last_message["content"])
]
last_message["content"].extend( # type: ignore[union-attr]
await async_prepare_files_for_prompt(
self.hass, [(a.path, a.mime_type) for a in last_content.attachments]
)
)
if structure and structure_name:
structure_name = slugify(structure_name)
if model_args["thinking"]["type"] == "disabled":
if not tools:
# Simplest case: no tools and no extended thinking
# Add a tool and force its use
model_args["tool_choice"] = ToolChoiceToolParam(
type="tool",
name=structure_name,
)
else:
# Second case: tools present but no extended thinking
# Allow the model to use any tool but not text response
# The model should know to use the right tool by its description
model_args["tool_choice"] = ToolChoiceAnyParam(
type="any",
)
else:
# Extended thinking is enabled. With extended thinking, we cannot
# force tool use or disable text responses, so we add a hint to the
# system prompt instead. With extended thinking, the model should be
# smart enough to use the tool.
model_args["tool_choice"] = ToolChoiceAutoParam(
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",
text=f"Claude MUST use the '{structure_name}' tool to provide the final answer instead of plain text.",
)
)
tools.append(
ToolParam(
name=structure_name,
description="Use this tool to reply to the user",
input_schema=convert(
structure,
custom_serializer=chat_log.llm_api.custom_serializer
if chat_log.llm_api
else llm.selector_serializer,
),
)
)
if tools: if tools:
model_args["tools"] = tools model_args["tools"] = tools
@@ -629,7 +735,11 @@ class AnthropicBaseLLMEntity(Entity):
content content
async for content in chat_log.async_add_delta_content_stream( async for content in chat_log.async_add_delta_content_stream(
self.entity_id, self.entity_id,
_transform_stream(chat_log, stream), _transform_stream(
chat_log,
stream,
output_tool=structure_name if structure else None,
),
) )
] ]
) )
@@ -641,3 +751,59 @@ class AnthropicBaseLLMEntity(Entity):
if not chat_log.unresponded_tool_results: if not chat_log.unresponded_tool_results:
break break
async def async_prepare_files_for_prompt(
hass: HomeAssistant, files: list[tuple[Path, str | None]]
) -> Iterable[ImageBlockParam | DocumentBlockParam]:
"""Append files to a prompt.
Caller needs to ensure that the files are allowed.
"""
def append_files_to_content() -> Iterable[ImageBlockParam | DocumentBlockParam]:
content: list[ImageBlockParam | DocumentBlockParam] = []
for file_path, mime_type in files:
if not file_path.exists():
raise HomeAssistantError(f"`{file_path}` does not exist")
if mime_type is None:
mime_type = guess_file_type(file_path)[0]
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
"Only images and PDF are supported by the Anthropic API,"
f"`{file_path}` is not an image file or PDF"
)
if mime_type == "image/jpg":
mime_type = "image/jpeg"
base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
if mime_type.startswith("image/"):
content.append(
ImageBlockParam(
type="image",
source=Base64ImageSourceParam(
type="base64",
media_type=mime_type, # type: ignore[typeddict-item]
data=base64_file,
),
)
)
elif mime_type.startswith("application/pdf"):
content.append(
DocumentBlockParam(
type="document",
source=Base64PDFSourceParam(
type="base64",
media_type=mime_type, # type: ignore[typeddict-item]
data=base64_file,
),
)
)
return content
return await hass.async_add_executor_job(append_files_to_content)

View File

@@ -18,6 +18,49 @@
} }
}, },
"config_subentries": { "config_subentries": {
"ai_task_data": {
"abort": {
"entry_not_loaded": "[%key:component::anthropic::config_subentries::conversation::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "AI task",
"initiate_flow": {
"reconfigure": "Reconfigure AI task",
"user": "Add AI task"
},
"step": {
"advanced": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
},
"init": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"recommended": "[%key:component::anthropic::config_subentries::conversation::step::init::data::recommended%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::init::title%]"
},
"model": {
"data": {
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
},
"data_description": {
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::model::title%]"
}
}
},
"conversation": { "conversation": {
"abort": { "abort": {
"entry_not_loaded": "Cannot add things while the configuration is disabled.", "entry_not_loaded": "Cannot add things while the configuration is disabled.",
@@ -46,7 +89,8 @@
}, },
"data_description": { "data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template." "prompt": "Instruct how the LLM should respond. This can be a template."
} },
"title": "Basic settings"
}, },
"model": { "model": {
"data": { "data": {

View File

@@ -37,13 +37,6 @@ USER_SCHEMA = vol.Schema(
} }
) )
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string}) STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
STEP_RECONFIGURE = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
@@ -175,19 +168,21 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle reconfiguration of the device.""" """Handle reconfiguration of the device."""
reconfigure_entry = self._get_reconfigure_entry() reconfigure_entry = self._get_reconfigure_entry()
if not user_input: errors: dict[str, str] = {}
return self.async_show_form(
step_id="reconfigure", data_schema=STEP_RECONFIGURE
)
if user_input is not None:
updated_host = user_input[CONF_HOST] updated_host = user_input[CONF_HOST]
self._async_abort_entries_match({CONF_HOST: updated_host}) self._async_abort_entries_match({CONF_HOST: updated_host})
errors: dict[str, str] = {}
try: try:
await validate_input(self.hass, user_input) data_to_validate = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
}
await validate_input(self.hass, data_to_validate)
except CannotConnect: except CannotConnect:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidAuth: except InvalidAuth:
@@ -198,13 +193,30 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
data_updates = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
}
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
reconfigure_entry, data_updates={CONF_HOST: updated_host} reconfigure_entry, data_updates=data_updates
)
schema = vol.Schema(
{
vol.Required(
CONF_HOST, default=reconfigure_entry.data[CONF_HOST]
): cv.string,
vol.Required(
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
): cv.port,
vol.Optional(CONF_PIN): cv.string,
}
) )
return self.async_show_form( return self.async_show_form(
step_id="reconfigure", step_id="reconfigure",
data_schema=STEP_RECONFIGURE, data_schema=schema,
errors=errors, errors=errors,
) )

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
import logging import logging
from typing import Literal from typing import Any, Literal
from hassil.recognize import RecognizeResult from hassil.recognize import RecognizeResult
import voluptuous as vol import voluptuous as vol
@@ -21,6 +21,7 @@ from homeassistant.core import (
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent from homeassistant.helpers import config_validation as cv, intent
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
@@ -52,6 +53,8 @@ from .const import (
DATA_COMPONENT, DATA_COMPONENT,
DOMAIN, DOMAIN,
HOME_ASSISTANT_AGENT, HOME_ASSISTANT_AGENT,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SERVICE_PROCESS, SERVICE_PROCESS,
SERVICE_RELOAD, SERVICE_RELOAD,
ConversationEntityFeature, ConversationEntityFeature,
@@ -266,10 +269,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component hass.data[DATA_COMPONENT] = entity_component
agent_config = config.get(DOMAIN, {}) manager = get_agent_manager(hass)
await async_setup_default_agent(
hass, entity_component, config_intents=agent_config.get("intents", {}) hass_config_path = hass.config.path()
) config_intents = _get_config_intents(config, hass_config_path)
manager.update_config_intents(config_intents)
await async_setup_default_agent(hass, entity_component)
async def handle_process(service: ServiceCall) -> ServiceResponse: async def handle_process(service: ServiceCall) -> ServiceResponse:
"""Parse text into commands.""" """Parse text into commands."""
@@ -294,9 +300,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_reload(service: ServiceCall) -> None: async def handle_reload(service: ServiceCall) -> None:
"""Reload intents.""" """Reload intents."""
agent = get_agent_manager(hass).default_agent language = service.data.get(ATTR_LANGUAGE)
if language is None:
conf = await async_integration_yaml_config(hass, DOMAIN)
if conf is not None:
config_intents = _get_config_intents(conf, hass_config_path)
manager.update_config_intents(config_intents)
agent = manager.default_agent
if agent is not None: if agent is not None:
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE)) await agent.async_reload(language=language)
hass.services.async_register( hass.services.async_register(
DOMAIN, DOMAIN,
@@ -313,6 +326,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True return True
def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str, Any]:
"""Return config intents."""
intents = config.get(DOMAIN, {}).get("intents", {})
return {
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in intents.items()
}
}
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry.""" """Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry) return await hass.data[DATA_COMPONENT].async_setup_entry(entry)

View File

@@ -147,6 +147,7 @@ class AgentManager:
self.hass = hass self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {} self._agents: dict[str, AbstractConversationAgent] = {}
self.default_agent: DefaultAgent | None = None self.default_agent: DefaultAgent | None = None
self.config_intents: dict[str, Any] = {}
self.triggers_details: list[TriggerDetails] = [] self.triggers_details: list[TriggerDetails] = []
@callback @callback
@@ -199,9 +200,16 @@ class AgentManager:
async def async_setup_default_agent(self, agent: DefaultAgent) -> None: async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
"""Set up the default agent.""" """Set up the default agent."""
agent.update_config_intents(self.config_intents)
agent.update_triggers(self.triggers_details) agent.update_triggers(self.triggers_details)
self.default_agent = agent self.default_agent = agent
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self.config_intents = intents
if self.default_agent is not None:
self.default_agent.update_config_intents(intents)
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE: def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
"""Register a trigger.""" """Register a trigger."""
self.triggers_details.append(trigger_details) self.triggers_details.append(trigger_details)

View File

@@ -30,3 +30,7 @@ class ConversationEntityFeature(IntFlag):
"""Supported features of the conversation entity.""" """Supported features of the conversation entity."""
CONTROL = 1 CONTROL = 1
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"

View File

@@ -77,7 +77,12 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import get_agent_manager from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog from .chat_log import AssistantContent, ChatLog
from .const import DOMAIN, ConversationEntityFeature from .const import (
DOMAIN,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
ConversationEntityFeature,
)
from .entity import ConversationEntity from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append from .trace import ConversationTraceEventType, async_conversation_trace_append
@@ -91,8 +96,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} _DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
METADATA_FUZZY_MATCH = "hass_fuzzy_match" METADATA_FUZZY_MATCH = "hass_fuzzy_match"
ERROR_SENTINEL = object() ERROR_SENTINEL = object()
@@ -202,10 +205,9 @@ class IntentCache:
async def async_setup_default_agent( async def async_setup_default_agent(
hass: HomeAssistant, hass: HomeAssistant,
entity_component: EntityComponent[ConversationEntity], entity_component: EntityComponent[ConversationEntity],
config_intents: dict[str, Any],
) -> None: ) -> None:
"""Set up entity registry listener for the default agent.""" """Set up entity registry listener for the default agent."""
agent = DefaultAgent(hass, config_intents) agent = DefaultAgent(hass)
await entity_component.async_add_entities([agent]) await entity_component.async_add_entities([agent])
await get_agent_manager(hass).async_setup_default_agent(agent) await get_agent_manager(hass).async_setup_default_agent(agent)
@@ -230,14 +232,14 @@ class DefaultAgent(ConversationEntity):
_attr_name = "Home Assistant" _attr_name = "Home Assistant"
_attr_supported_features = ConversationEntityFeature.CONTROL _attr_supported_features = ConversationEntityFeature.CONTROL
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the default agent.""" """Initialize the default agent."""
self.hass = hass self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {} self._lang_intents: dict[str, LanguageIntents | object] = {}
self._load_intents_lock = asyncio.Lock() self._load_intents_lock = asyncio.Lock()
# intent -> [sentences] # Intents from common conversation config
self._config_intents: dict[str, Any] = config_intents self._config_intents: dict[str, Any] = {}
# Sentences that will trigger a callback (skipping intent recognition) # Sentences that will trigger a callback (skipping intent recognition)
self._triggers_details: list[TriggerDetails] = [] self._triggers_details: list[TriggerDetails] = []
@@ -1035,6 +1037,14 @@ class DefaultAgent(ConversationEntity):
# Intents have changed, so we must clear the cache # Intents have changed, so we must clear the cache
self._intent_cache.clear() 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: async def async_prepare(self, language: str | None = None) -> None:
"""Load intents for a language.""" """Load intents for a language."""
if language is None: if language is None:
@@ -1159,32 +1169,9 @@ class DefaultAgent(ConversationEntity):
custom_sentences_path, custom_sentences_path,
) )
# Load sentences from HA config for default language only
if self._config_intents and (
self.hass.config.language in (language, language_variant)
):
hass_config_path = self.hass.config.path()
merge_dict( merge_dict(
intents_dict, intents_dict,
{ self._config_intents,
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in self._config_intents.items()
}
},
)
_LOGGER.debug(
"Loaded intents from configuration.yaml",
) )
if not intents_dict: if not intents_dict:

View File

@@ -116,20 +116,28 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
is_open_wdr = None is_open_wdr = None
is_open_hdr = None is_open_hdr = None
reserve3 = product_info.get("reserve4") reserve3 = product_info.get("reserve4")
model = product_info.get("model")
model_int = int(model) if model is not None else 7002
if model_int > 7001:
reserve3_int = int(reserve3) if reserve3 is not None else 0 reserve3_int = int(reserve3) if reserve3 is not None else 0
supports_wdr_adjustment_val = bool(int(reserve3_int & 256)) supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
supports_hdr_adjustment_val = bool(int(reserve3_int & 128)) supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
if supports_wdr_adjustment_val: if supports_wdr_adjustment_val:
ret_wdr, is_open_wdr_data = self.session.getWdrMode() ret_wdr, is_open_wdr_data = self.session.getWdrMode()
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0 mode = (
is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
)
is_open_wdr = bool(int(mode)) is_open_wdr = bool(int(mode))
elif supports_hdr_adjustment_val: elif supports_hdr_adjustment_val:
ret_hdr, is_open_hdr_data = self.session.getHdrMode() ret_hdr, is_open_hdr_data = self.session.getHdrMode()
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0 mode = (
is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
)
is_open_hdr = bool(int(mode)) is_open_hdr = bool(int(mode))
else:
supports_wdr_adjustment_val = False
supports_hdr_adjustment_val = False
ret_sw, software_capabilities = self.session.getSWCapabilities() ret_sw, software_capabilities = self.session.getSWCapabilities()
supports_speak_volume_adjustment_val = ( supports_speak_volume_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities1")) & 32) bool(int(software_capabilities.get("swCapabilities1")) & 32)
if ret_sw == 0 if ret_sw == 0

View File

@@ -6,4 +6,6 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984 HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
# Don't forget to update the docker sha256 in script/hassfest/docker.py too
RECOMMENDED_VERSION = "1.9.11" RECOMMENDED_VERSION = "1.9.11"

View File

@@ -1,77 +0,0 @@
"""Support for the Hive alarm."""
from __future__ import annotations
from datetime import timedelta
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HiveConfigEntry
from .entity import HiveEntity
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(seconds=15)
HIVETOHA = {
"home": AlarmControlPanelState.DISARMED,
"asleep": AlarmControlPanelState.ARMED_NIGHT,
"away": AlarmControlPanelState.ARMED_AWAY,
"sos": AlarmControlPanelState.TRIGGERED,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: HiveConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
hive = entry.runtime_data
if devices := hive.session.deviceList.get("alarm_control_panel"):
async_add_entities(
[HiveAlarmControlPanelEntity(hive, dev) for dev in devices], True
)
class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity):
"""Representation of a Hive alarm."""
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_NIGHT
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.TRIGGER
)
_attr_code_arm_required = False
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
await self.hive.alarm.setMode(self.device, "home")
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self.hive.alarm.setMode(self.device, "asleep")
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.hive.alarm.setMode(self.device, "away")
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send alarm trigger command."""
await self.hive.alarm.setMode(self.device, "sos")
async def async_update(self) -> None:
"""Update all Node data from Hive."""
await self.hive.session.updateData(self.device)
self.device = await self.hive.alarm.getAlarm(self.device)
self._attr_available = self.device["deviceData"].get("online")
if self._attr_available:
if self.device["status"]["state"]:
self._attr_alarm_state = AlarmControlPanelState.TRIGGERED
else:
self._attr_alarm_state = HIVETOHA[self.device["status"]["mode"]]

View File

@@ -11,7 +11,6 @@ CONFIG_ENTRY_VERSION = 1
DEFAULT_NAME = "Hive" DEFAULT_NAME = "Hive"
DOMAIN = "hive" DOMAIN = "hive"
PLATFORMS = [ PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.CLIMATE, Platform.CLIMATE,
Platform.LIGHT, Platform.LIGHT,
@@ -20,7 +19,6 @@ PLATFORMS = [
Platform.WATER_HEATER, Platform.WATER_HEATER,
] ]
PLATFORM_LOOKUP = { PLATFORM_LOOKUP = {
Platform.ALARM_CONTROL_PANEL: "alarm_control_panel",
Platform.BINARY_SENSOR: "binary_sensor", Platform.BINARY_SENSOR: "binary_sensor",
Platform.CLIMATE: "climate", Platform.CLIMATE: "climate",
Platform.LIGHT: "light", Platform.LIGHT: "light",

View File

@@ -9,5 +9,5 @@
}, },
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["apyhiveapi"], "loggers": ["apyhiveapi"],
"requirements": ["pyhive-integration==1.0.6"] "requirements": ["pyhive-integration==1.0.7"]
} }

View File

@@ -1237,7 +1237,7 @@
"message": "Error obtaining data from the API: {error}" "message": "Error obtaining data from the API: {error}"
}, },
"oauth2_implementation_unavailable": { "oauth2_implementation_unavailable": {
"message": "OAuth2 implementation temporarily unavailable, will retry" "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}, },
"pause_program": { "pause_program": {
"message": "Error pausing program: {error}" "message": "Error pausing program: {error}"

View File

@@ -4,6 +4,7 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from aiopvapi.resources.model import PowerviewData from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.rooms import Rooms from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes from aiopvapi.scenes import Scenes
from aiopvapi.shades import Shades from aiopvapi.shades import Shades
@@ -16,7 +17,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewConfigEntry, PowerviewEntryData from .model import PowerviewConfigEntry, PowerviewEntryData
from .shade_data import PowerviewShadeData
from .util import async_connect_hub from .util import async_connect_hub
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1

View File

@@ -8,6 +8,7 @@ import logging
from aiopvapi.helpers.aiorequest import PvApiMaintenance from aiopvapi.helpers.aiorequest import PvApiMaintenance
from aiopvapi.hub import Hub from aiopvapi.hub import Hub
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.shades import Shades from aiopvapi.shades import Shades
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -15,7 +16,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import HUB_EXCEPTIONS from .const import HUB_EXCEPTIONS
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@@ -208,13 +208,13 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
async def _async_execute_move(self, move: ShadePosition) -> None: async def _async_execute_move(self, move: ShadePosition) -> None:
"""Execute a move that can affect multiple positions.""" """Execute a move that can affect multiple positions."""
_LOGGER.debug("Move request %s: %s", self.name, move) _LOGGER.debug("Move request %s: %s", self.name, move)
# Store the requested positions so subsequent move
# requests contain the secondary shade positions
self.data.update_shade_position(self._shade.id, move)
async with self.coordinator.radio_operation_lock: async with self.coordinator.radio_operation_lock:
response = await self._shade.move(move) response = await self._shade.move(move)
_LOGGER.debug("Move response %s: %s", self.name, response) _LOGGER.debug("Move response %s: %s", self.name, response)
# Process the response from the hub (including new positions)
self.data.update_shade_position(self._shade.id, response)
async def _async_set_cover_position(self, target_hass_position: int) -> None: async def _async_set_cover_position(self, target_hass_position: int) -> None:
"""Move the shade to a position.""" """Move the shade to a position."""
target_hass_position = self._clamp_cover_limit(target_hass_position) target_hass_position = self._clamp_cover_limit(target_hass_position)

View File

@@ -3,6 +3,7 @@
import logging import logging
from aiopvapi.resources.shade import BaseShade, ShadePosition from aiopvapi.resources.shade import BaseShade, ShadePosition
from aiopvapi.resources.shade_data import PowerviewShadeData
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@@ -11,7 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER from .const import DOMAIN, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewDeviceInfo from .model import PowerviewDeviceInfo
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@@ -18,6 +18,6 @@
}, },
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aiopvapi"], "loggers": ["aiopvapi"],
"requirements": ["aiopvapi==3.2.1"], "requirements": ["aiopvapi==3.3.0"],
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."] "zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
} }

View File

@@ -1,80 +0,0 @@
"""Shade data for the Hunter Douglas PowerView integration."""
from __future__ import annotations
from dataclasses import fields
from typing import Any
from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade import BaseShade, ShadePosition
from .util import async_map_data_by_id
POSITION_FIELDS = [field for field in fields(ShadePosition) if field.name != "velocity"]
def copy_position_data(source: ShadePosition, target: ShadePosition) -> ShadePosition:
"""Copy position data from source to target for None values only."""
for field in POSITION_FIELDS:
if (value := getattr(source, field.name)) is not None:
setattr(target, field.name, value)
class PowerviewShadeData:
"""Coordinate shade data between multiple api calls."""
def __init__(self) -> None:
"""Init the shade data."""
self._raw_data_by_id: dict[int, dict[str | int, Any]] = {}
self._shade_group_data_by_id: dict[int, BaseShade] = {}
self.positions: dict[int, ShadePosition] = {}
def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
"""Get data for the shade."""
return self._raw_data_by_id[shade_id]
def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]:
"""Get data for all shades."""
return self._raw_data_by_id
def get_shade(self, shade_id: int) -> BaseShade:
"""Get specific shade from the coordinator."""
return self._shade_group_data_by_id[shade_id]
def get_shade_position(self, shade_id: int) -> ShadePosition:
"""Get positions for a shade."""
if shade_id not in self.positions:
shade_position = ShadePosition()
# If we have the group data, use it to populate the initial position
if shade := self._shade_group_data_by_id.get(shade_id):
copy_position_data(shade.current_position, shade_position)
self.positions[shade_id] = shade_position
return self.positions[shade_id]
def update_from_group_data(self, shade_id: int) -> None:
"""Process an update from the group data."""
data = self._shade_group_data_by_id[shade_id]
copy_position_data(data.current_position, self.get_shade_position(data.id))
def store_group_data(self, shade_data: PowerviewData) -> None:
"""Store data from the all shades endpoint.
This does not update the shades or positions (self.positions)
as the data may be stale. update_from_group_data
with a shade_id will update a specific shade
from the group data.
"""
self._shade_group_data_by_id = shade_data.processed
self._raw_data_by_id = async_map_data_by_id(shade_data.raw)
def update_shade_position(self, shade_id: int, new_position: ShadePosition) -> None:
"""Update a single shades position."""
copy_position_data(new_position, self.get_shade_position(shade_id))
def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None:
"""Update a single shades velocity."""
# the hub will always return a velocity of 0 on initial connect,
# separate definition to store consistent value in HA
# this value is purely driven from HA
if shade_data.velocity is not None:
self.get_shade_position(shade_id).velocity = shade_data.velocity

View File

@@ -2,25 +2,15 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.helpers.constants import ATTR_ID
from aiopvapi.hub import Hub from aiopvapi.hub import Hub
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .model import PowerviewAPI, PowerviewDeviceInfo from .model import PowerviewAPI, PowerviewDeviceInfo
@callback
def async_map_data_by_id(data: Iterable[dict[str | int, Any]]):
"""Return a dict with the key being the id for a list of entries."""
return {entry[ATTR_ID]: entry for entry in data}
async def async_connect_hub( async def async_connect_hub(
hass: HomeAssistant, address: str, api_version: int | None = None hass: HomeAssistant, address: str, api_version: int | None = None
) -> PowerviewAPI: ) -> PowerviewAPI:

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from propcache.api import cached_property
from pyituran import Vehicle from pyituran import Vehicle
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
@@ -69,7 +68,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
super().__init__(coordinator, license_plate, description.key) super().__init__(coordinator, license_plate, description.key)
self.entity_description = description self.entity_description = description
@cached_property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if the binary sensor is on.""" """Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.vehicle) return self.entity_description.value_fn(self.vehicle)

View File

@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from propcache.api import cached_property
from homeassistant.components.device_tracker import TrackerEntity from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -40,12 +38,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
"""Initialize the device tracker.""" """Initialize the device tracker."""
super().__init__(coordinator, license_plate, "device_tracker") super().__init__(coordinator, license_plate, "device_tracker")
@cached_property @property
def latitude(self) -> float | None: def latitude(self) -> float | None:
"""Return latitude value of the device.""" """Return latitude value of the device."""
return self.vehicle.gps_coordinates[0] return self.vehicle.gps_coordinates[0]
@cached_property @property
def longitude(self) -> float | None: def longitude(self) -> float | None:
"""Return longitude value of the device.""" """Return longitude value of the device."""
return self.vehicle.gps_coordinates[1] return self.vehicle.gps_coordinates[1]

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from propcache.api import cached_property
from pyituran import Vehicle from pyituran import Vehicle
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@@ -133,7 +132,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
super().__init__(coordinator, license_plate, description.key) super().__init__(coordinator, license_plate, description.key)
self.entity_description = description self.entity_description = description
@cached_property @property
def native_value(self) -> StateType | datetime: def native_value(self) -> StateType | datetime:
"""Return the state of the device.""" """Return the state of the device."""
return self.entity_description.value_fn(self.vehicle) return self.entity_description.value_fn(self.vehicle)

View File

@@ -94,28 +94,6 @@
} }
}, },
"services": { "services": {
"address_to_device_id": {
"description": "Converts an LCN address into a device ID.",
"fields": {
"host": {
"description": "Host name as given in the integration panel.",
"name": "Host name"
},
"id": {
"description": "Module or group number of the target.",
"name": "Module or group ID"
},
"segment_id": {
"description": "Segment number of the target.",
"name": "Segment ID"
},
"type": {
"description": "Module type of the target.",
"name": "Type"
}
},
"name": "Address to device ID"
},
"dyn_text": { "dyn_text": {
"description": "Sends dynamic text to LCN-GTxD displays.", "description": "Sends dynamic text to LCN-GTxD displays.",
"fields": { "fields": {

View File

@@ -353,17 +353,13 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
# DeviceFault or SupplyFault bit enabled # DeviceFault or SupplyFault bit enabled
device_to_ha={ device_to_ha=lambda x: bool(
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True, x
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True, & (
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False, clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedHigh: False, | clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kLocalOverride: False, )
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning: False, ),
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemotePressure: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteFlow: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteTemperature: False,
}.get,
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
required_attributes=( required_attributes=(
@@ -377,9 +373,9 @@ DISCOVERY_SCHEMAS = [
key="PumpStatusRunning", key="PumpStatusRunning",
translation_key="pump_running", translation_key="pump_running",
device_class=BinarySensorDeviceClass.RUNNING, device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha=lambda x: ( device_to_ha=lambda x: bool(
x x
== clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning & clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -395,8 +391,8 @@ DISCOVERY_SCHEMAS = [
translation_key="dishwasher_alarm_inflow", translation_key="dishwasher_alarm_inflow",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( device_to_ha=lambda x: bool(
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -410,8 +406,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door", translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( device_to_ha=lambda x: bool(
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -425,9 +421,10 @@ DISCOVERY_SCHEMAS = [
translation_key="valve_fault_general_fault", translation_key="valve_fault_general_fault",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( # GeneralFault bit from ValveFault attribute
device_to_ha=lambda x: bool(
x x
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault & clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -443,9 +440,10 @@ DISCOVERY_SCHEMAS = [
translation_key="valve_fault_blocked", translation_key="valve_fault_blocked",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( # Blocked bit from ValveFault attribute
device_to_ha=lambda x: bool(
x x
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked & clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -461,9 +459,10 @@ DISCOVERY_SCHEMAS = [
translation_key="valve_fault_leaking", translation_key="valve_fault_leaking",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( # Leaking bit from ValveFault attribute
device_to_ha=lambda x: bool(
x x
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking & clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,
@@ -478,8 +477,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door", translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM, device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: ( device_to_ha=lambda x: bool(
x == clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen x & clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
), ),
), ),
entity_class=MatterBinarySensor, entity_class=MatterBinarySensor,

View File

@@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aiomealie==1.0.1"] "requirements": ["aiomealie==1.1.0"]
} }

View File

@@ -1009,7 +1009,7 @@
"cleaning_care_program": "Cleaning/care program", "cleaning_care_program": "Cleaning/care program",
"maintenance_program": "Maintenance program", "maintenance_program": "Maintenance program",
"normal_operation_mode": "Normal operation mode", "normal_operation_mode": "Normal operation mode",
"own_program": "Own program" "own_program": "Program"
} }
}, },
"remaining_time": { "remaining_time": {
@@ -1089,7 +1089,7 @@
"message": "Invalid device targeted." "message": "Invalid device targeted."
}, },
"oauth2_implementation_unavailable": { "oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry" "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}, },
"set_program_error": { "set_program_error": {
"message": "'Set program' action failed: {status} / {message}" "message": "'Set program' action failed: {status} / {message}"

View File

@@ -2,7 +2,7 @@
"domain": "music_assistant", "domain": "music_assistant",
"name": "Music Assistant", "name": "Music Assistant",
"after_dependencies": ["media_source", "media_player"], "after_dependencies": ["media_source", "media_player"],
"codeowners": ["@music-assistant"], "codeowners": ["@music-assistant", "@arturpragacz"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/music_assistant", "documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push", "iot_class": "local_push",

View File

@@ -57,7 +57,7 @@
"message": "Error while loading the integration." "message": "Error while loading the integration."
}, },
"implementation_unavailable": { "implementation_unavailable": {
"message": "OAuth2 implementation is not available, will retry." "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}, },
"incorrect_oauth2_scope": { "incorrect_oauth2_scope": {
"message": "Stored permissions are invalid. Please login again to update permissions." "message": "Stored permissions are invalid. Please login again to update permissions."

View File

@@ -12,7 +12,12 @@ from homeassistant.helpers import entity_registry as er
from .const import _LOGGER from .const import _LOGGER
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE] PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
]
type NikoHomeControlConfigEntry = ConfigEntry[NHCController] type NikoHomeControlConfigEntry = ConfigEntry[NHCController]

View File

@@ -0,0 +1,100 @@
"""Support for Niko Home Control thermostats."""
from typing import Any
from nhc.const import THERMOSTAT_MODES, THERMOSTAT_MODES_REVERSE
from nhc.thermostat import NHCThermostat
from homeassistant.components.climate import (
PRESET_ECO,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.components.sensor import UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NikoHomeControlConfigEntry
from .const import (
NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP,
NikoHomeControlThermostatModes,
)
from .entity import NikoHomeControlEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: NikoHomeControlConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Niko Home Control thermostat entry."""
controller = entry.runtime_data
async_add_entities(
NikoHomeControlClimate(thermostat, controller, entry.entry_id)
for thermostat in controller.thermostats.values()
)
class NikoHomeControlClimate(NikoHomeControlEntity, ClimateEntity):
"""Representation of a Niko Home Control thermostat."""
_attr_supported_features: ClimateEntityFeature = (
ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_name = None
_action: NHCThermostat
_attr_translation_key = "nhc_thermostat"
_attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.AUTO]
_attr_preset_modes = [
"day",
"night",
PRESET_ECO,
"prog1",
"prog2",
"prog3",
]
def _get_niko_mode(self, mode: str) -> int:
"""Return the Niko mode."""
return THERMOSTAT_MODES_REVERSE.get(mode, NikoHomeControlThermostatModes.OFF)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if ATTR_TEMPERATURE in kwargs:
await self._action.set_temperature(kwargs.get(ATTR_TEMPERATURE))
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self._action.set_mode(self._get_niko_mode(preset_mode))
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
await self._action.set_mode(NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP[hvac_mode])
async def async_turn_off(self) -> None:
"""Turn thermostat off."""
await self._action.set_mode(NikoHomeControlThermostatModes.OFF)
def update_state(self) -> None:
"""Update the state of the entity."""
if self._action.state == NikoHomeControlThermostatModes.OFF:
self._attr_hvac_mode = HVACMode.OFF
self._attr_preset_mode = None
elif self._action.state == NikoHomeControlThermostatModes.COOL:
self._attr_hvac_mode = HVACMode.COOL
self._attr_preset_mode = None
else:
self._attr_hvac_mode = HVACMode.AUTO
self._attr_preset_mode = THERMOSTAT_MODES[self._action.state]
self._attr_target_temperature = self._action.setpoint
self._attr_current_temperature = self._action.measured

View File

@@ -1,6 +1,23 @@
"""Constants for niko_home_control integration.""" """Constants for niko_home_control integration."""
from enum import IntEnum
import logging import logging
from homeassistant.components.climate import HVACMode
DOMAIN = "niko_home_control" DOMAIN = "niko_home_control"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP = {
HVACMode.OFF: 3,
HVACMode.COOL: 4,
HVACMode.AUTO: 5,
}
class NikoHomeControlThermostatModes(IntEnum):
"""Enum for Niko Home Control thermostat modes."""
OFF = 3
COOL = 4
AUTO = 5

View File

@@ -0,0 +1,20 @@
{
"entity": {
"climate": {
"nhc_thermostat": {
"state_attributes": {
"preset_mode": {
"default": "mdi:calendar-clock",
"state": {
"day": "mdi:weather-sunny",
"night": "mdi:weather-night",
"prog1": "mdi:numeric-1",
"prog2": "mdi:numeric-2",
"prog3": "mdi:numeric-3"
}
}
}
}
}
}
}

View File

@@ -26,5 +26,23 @@
"description": "Set up your Niko Home Control instance." "description": "Set up your Niko Home Control instance."
} }
} }
},
"entity": {
"climate": {
"nhc_thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"day": "Day",
"eco": "Eco",
"night": "Night",
"prog1": "Program 1",
"prog2": "Program 2",
"prog3": "Program 3"
}
}
}
}
}
} }
} }

View File

@@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/palazzetti", "documentation": "https://www.home-assistant.io/integrations/palazzetti",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["pypalazzetti==0.1.19"] "requirements": ["pypalazzetti==0.1.20"]
} }

View File

@@ -256,6 +256,7 @@ class PlaystationNetworkFriendDataCoordinator(
account_id=self.user.account_id, account_id=self.user.account_id,
presence=self.user.get_presence(), presence=self.user.get_presence(),
profile=self.profile, profile=self.profile,
trophy_summary=self.user.trophy_summary(),
) )
except PSNAWPForbiddenError as error: except PSNAWPForbiddenError as error:
raise UpdateFailed( raise UpdateFailed(

View File

@@ -54,7 +54,7 @@ class PlaystationNetworkSensor(StrEnum):
NOW_PLAYING = "now_playing" NOW_PLAYING = "now_playing"
SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription( PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.TROPHY_LEVEL, key=PlaystationNetworkSensor.TROPHY_LEVEL,
translation_key=PlaystationNetworkSensor.TROPHY_LEVEL, translation_key=PlaystationNetworkSensor.TROPHY_LEVEL,
@@ -106,8 +106,6 @@ SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...
else None else None
), ),
), ),
)
SENSOR_DESCRIPTIONS_USER: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription( PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.ONLINE_ID, key=PlaystationNetworkSensor.ONLINE_ID,
translation_key=PlaystationNetworkSensor.ONLINE_ID, translation_key=PlaystationNetworkSensor.ONLINE_ID,
@@ -152,7 +150,7 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data.user_data coordinator = config_entry.runtime_data.user_data
async_add_entities( async_add_entities(
PlaystationNetworkSensorEntity(coordinator, description) PlaystationNetworkSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS_TROPHY + SENSOR_DESCRIPTIONS_USER for description in SENSOR_DESCRIPTIONS
) )
for ( for (
@@ -166,7 +164,7 @@ async def async_setup_entry(
description, description,
config_entry.subentries[subentry_id], config_entry.subentries[subentry_id],
) )
for description in SENSOR_DESCRIPTIONS_USER for description in SENSOR_DESCRIPTIONS
], ],
config_subentry_id=subentry_id, config_subentry_id=subentry_id,
) )

View File

@@ -57,12 +57,14 @@ type SelectType = Literal[
"select_gateway_mode", "select_gateway_mode",
"select_regulation_mode", "select_regulation_mode",
"select_schedule", "select_schedule",
"select_zone_profile",
] ]
type SelectOptionsType = Literal[ type SelectOptionsType = Literal[
"available_schedules",
"dhw_modes", "dhw_modes",
"gateway_modes", "gateway_modes",
"regulation_modes", "regulation_modes",
"available_schedules", "zone_profiles",
] ]
# Default directives # Default directives
@@ -82,3 +84,10 @@ MASTER_THERMOSTATS: Final[list[str]] = [
"zone_thermometer", "zone_thermometer",
"zone_thermostat", "zone_thermostat",
] ]
# Select constants
SELECT_DHW_MODE: Final = "select_dhw_mode"
SELECT_GATEWAY_MODE: Final = "select_gateway_mode"
SELECT_REGULATION_MODE: Final = "select_regulation_mode"
SELECT_SCHEDULE: Final = "select_schedule"
SELECT_ZONE_PROFILE: Final = "select_zone_profile"

View File

@@ -8,6 +8,6 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["plugwise"], "loggers": ["plugwise"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["plugwise==1.9.0"], "requirements": ["plugwise==1.10.0"],
"zeroconf": ["_plugwise._tcp.local."] "zeroconf": ["_plugwise._tcp.local."]
} }

View File

@@ -9,7 +9,15 @@ from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SelectOptionsType, SelectType from .const import (
SELECT_DHW_MODE,
SELECT_GATEWAY_MODE,
SELECT_REGULATION_MODE,
SELECT_SCHEDULE,
SELECT_ZONE_PROFILE,
SelectOptionsType,
SelectType,
)
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity from .entity import PlugwiseEntity
from .util import plugwise_command from .util import plugwise_command
@@ -27,28 +35,34 @@ class PlugwiseSelectEntityDescription(SelectEntityDescription):
SELECT_TYPES = ( SELECT_TYPES = (
PlugwiseSelectEntityDescription( PlugwiseSelectEntityDescription(
key="select_schedule", key=SELECT_SCHEDULE,
translation_key="select_schedule", translation_key=SELECT_SCHEDULE,
options_key="available_schedules", options_key="available_schedules",
), ),
PlugwiseSelectEntityDescription( PlugwiseSelectEntityDescription(
key="select_regulation_mode", key=SELECT_REGULATION_MODE,
translation_key="regulation_mode", translation_key=SELECT_REGULATION_MODE,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
options_key="regulation_modes", options_key="regulation_modes",
), ),
PlugwiseSelectEntityDescription( PlugwiseSelectEntityDescription(
key="select_dhw_mode", key=SELECT_DHW_MODE,
translation_key="dhw_mode", translation_key=SELECT_DHW_MODE,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
options_key="dhw_modes", options_key="dhw_modes",
), ),
PlugwiseSelectEntityDescription( PlugwiseSelectEntityDescription(
key="select_gateway_mode", key=SELECT_GATEWAY_MODE,
translation_key="gateway_mode", translation_key=SELECT_GATEWAY_MODE,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
options_key="gateway_modes", options_key="gateway_modes",
), ),
PlugwiseSelectEntityDescription(
key=SELECT_ZONE_PROFILE,
translation_key=SELECT_ZONE_PROFILE,
entity_category=EntityCategory.CONFIG,
options_key="zone_profiles",
),
) )

View File

@@ -109,7 +109,7 @@
} }
}, },
"select": { "select": {
"dhw_mode": { "select_dhw_mode": {
"name": "DHW mode", "name": "DHW mode",
"state": { "state": {
"auto": "[%key:common::state::auto%]", "auto": "[%key:common::state::auto%]",
@@ -118,7 +118,7 @@
"off": "[%key:common::state::off%]" "off": "[%key:common::state::off%]"
} }
}, },
"gateway_mode": { "select_gateway_mode": {
"name": "Gateway mode", "name": "Gateway mode",
"state": { "state": {
"away": "Pause", "away": "Pause",
@@ -126,7 +126,7 @@
"vacation": "Vacation" "vacation": "Vacation"
} }
}, },
"regulation_mode": { "select_regulation_mode": {
"name": "Regulation mode", "name": "Regulation mode",
"state": { "state": {
"bleeding_cold": "Bleeding cold", "bleeding_cold": "Bleeding cold",
@@ -141,6 +141,14 @@
"state": { "state": {
"off": "[%key:common::state::off%]" "off": "[%key:common::state::off%]"
} }
},
"select_zone_profile": {
"name": "Zone profile",
"state": {
"active": "[%key:common::state::active%]",
"off": "[%key:common::state::off%]",
"passive": "Passive"
}
} }
}, },
"sensor": { "sensor": {

View File

@@ -26,6 +26,9 @@ def validate_db_schema(instance: Recorder) -> set[str]:
schema_errors |= validate_table_schema_supports_utf8( schema_errors |= validate_table_schema_supports_utf8(
instance, StatisticsMeta, (StatisticsMeta.statistic_id,) instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
) )
schema_errors |= validate_table_schema_has_correct_collation(
instance, StatisticsMeta
)
for table in (Statistics, StatisticsShortTerm): for table in (Statistics, StatisticsShortTerm):
schema_errors |= validate_db_schema_precision(instance, table) schema_errors |= validate_db_schema_precision(instance, table)
schema_errors |= validate_table_schema_has_correct_collation(instance, table) schema_errors |= validate_table_schema_has_correct_collation(instance, table)

View File

@@ -54,7 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
EVENT_TYPE_IDS_SCHEMA_VERSION = 37 EVENT_TYPE_IDS_SCHEMA_VERSION = 37
STATES_META_SCHEMA_VERSION = 38 STATES_META_SCHEMA_VERSION = 38
CIRCULAR_MEAN_SCHEMA_VERSION = 49 CIRCULAR_MEAN_SCHEMA_VERSION = 49
UNIT_CLASS_SCHEMA_VERSION = 51 UNIT_CLASS_SCHEMA_VERSION = 52
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43 LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43

View File

@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration.""" """Base class for tables, used for schema migration."""
SCHEMA_VERSION = 51 SCHEMA_VERSION = 52
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)

View File

@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
from uuid import UUID from uuid import UUID
import sqlalchemy import sqlalchemy
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update from sqlalchemy import (
ForeignKeyConstraint,
MetaData,
Table,
cast as cast_,
func,
text,
update,
)
from sqlalchemy.engine import CursorResult, Engine from sqlalchemy.engine import CursorResult, Engine
from sqlalchemy.exc import ( from sqlalchemy.exc import (
DatabaseError, DatabaseError,
@@ -26,8 +34,9 @@ from sqlalchemy.exc import (
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
from sqlalchemy.sql.expression import true from sqlalchemy.sql.expression import and_, true
from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.lambdas import StatementLambdaElement
from sqlalchemy.types import BINARY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.util.enum import try_parse_enum from homeassistant.util.enum import try_parse_enum
@@ -2044,14 +2053,74 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51): class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
def _apply_update(self) -> None: def _apply_update(self) -> None:
"""Version specific update method.""" """Version specific update method."""
# Add unit class column to StatisticsMeta # Replaced with version 52 which corrects issues with MySQL string comparisons.
class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
def _apply_update(self) -> None:
"""Version specific update method."""
if self.engine.dialect.name == SupportedDialect.MYSQL:
self._apply_update_mysql()
else:
self._apply_update_postgresql_sqlite()
def _apply_update_mysql(self) -> None:
"""Version specific update method for mysql."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"]) _add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session: with session_scope(session=self.session_maker()) as session:
connection = session.connection() connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS: for conv in _PRIMARY_UNIT_CONVERTERS:
case_sensitive_units = {
u.encode("utf-8") if u else u for u in conv.VALID_UNITS
}
# Reset unit_class to None for entries that do not match
# the valid units (case sensitive) but matched before due to
# case insensitive comparisons.
connection.execute( connection.execute(
update(StatisticsMeta) update(StatisticsMeta)
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS)) .where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
cast_(StatisticsMeta.unit_of_measurement, BINARY).not_in(
case_sensitive_units
),
)
)
.values(unit_class=None)
)
# Do an explicitly case sensitive match (actually binary) to set the
# correct unit_class. This is needed because we use the case sensitive
# utf8mb4_unicode_ci collation.
connection.execute(
update(StatisticsMeta)
.where(
and_(
cast_(StatisticsMeta.unit_of_measurement, BINARY).in_(
case_sensitive_units
),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS)
)
def _apply_update_postgresql_sqlite(self) -> None:
"""Version specific update method for postgresql and sqlite."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS:
# Set the correct unit_class. Unlike MySQL, Postgres and SQLite
# have case sensitive string comparisons by default, so we
# can directly match on the valid units.
connection.execute(
update(StatisticsMeta)
.where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS) .values(unit_class=conv.UNIT_CLASS)
) )

View File

@@ -26,7 +26,7 @@ CACHE_SIZE = 8192
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
QUERY_STATISTIC_META = ( QUERY_STATISTICS_META = (
StatisticsMeta.id, StatisticsMeta.id,
StatisticsMeta.statistic_id, StatisticsMeta.statistic_id,
StatisticsMeta.source, StatisticsMeta.source,
@@ -55,7 +55,7 @@ def _generate_get_metadata_stmt(
Depending on the schema version, either mean_type (added in version 49) or has_mean column is used. Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
""" """
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META) columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTICS_META)
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION: if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
columns.append(StatisticsMeta.mean_type) columns.append(StatisticsMeta.mean_type)
else: else:

View File

@@ -2,12 +2,15 @@
from __future__ import annotations from __future__ import annotations
from satel_integra.satel_integra import AsyncSatel
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
) )
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -17,6 +20,7 @@ from .const import (
CONF_ZONE_NUMBER, CONF_ZONE_NUMBER,
CONF_ZONE_TYPE, CONF_ZONE_TYPE,
CONF_ZONES, CONF_ZONES,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED, SIGNAL_OUTPUTS_UPDATED,
SIGNAL_ZONES_UPDATED, SIGNAL_ZONES_UPDATED,
SUBENTRY_TYPE_OUTPUT, SUBENTRY_TYPE_OUTPUT,
@@ -40,9 +44,9 @@ async def async_setup_entry(
) )
for subentry in zone_subentries: for subentry in zone_subentries:
zone_num = subentry.data[CONF_ZONE_NUMBER] zone_num: int = subentry.data[CONF_ZONE_NUMBER]
zone_type = subentry.data[CONF_ZONE_TYPE] zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
zone_name = subentry.data[CONF_NAME] zone_name: str = subentry.data[CONF_NAME]
async_add_entities( async_add_entities(
[ [
@@ -65,9 +69,9 @@ async def async_setup_entry(
) )
for subentry in output_subentries: for subentry in output_subentries:
output_num = subentry.data[CONF_OUTPUT_NUMBER] output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
ouput_type = subentry.data[CONF_ZONE_TYPE] ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
output_name = subentry.data[CONF_NAME] output_name: str = subentry.data[CONF_NAME]
async_add_entities( async_add_entities(
[ [
@@ -89,68 +93,48 @@ class SatelIntegraBinarySensor(BinarySensorEntity):
"""Representation of an Satel Integra binary sensor.""" """Representation of an Satel Integra binary sensor."""
_attr_should_poll = False _attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__( def __init__(
self, self,
controller, controller: AsyncSatel,
device_number, device_number: int,
device_name, device_name: str,
zone_type, device_class: BinarySensorDeviceClass,
sensor_type, sensor_type: str,
react_to_signal, react_to_signal: str,
config_entry_id, config_entry_id: str,
): ) -> None:
"""Initialize the binary_sensor.""" """Initialize the binary_sensor."""
self._device_number = device_number self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}" self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
self._name = device_name
self._zone_type = zone_type
self._state = 0
self._react_to_signal = react_to_signal self._react_to_signal = react_to_signal
self._satel = controller self._satel = controller
self._attr_device_class = device_class
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """Register callbacks."""
if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED: if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED:
if self._device_number in self._satel.violated_outputs: self._attr_is_on = self._device_number in self._satel.violated_outputs
self._state = 1
else: else:
self._state = 0 self._attr_is_on = self._device_number in self._satel.violated_zones
elif self._device_number in self._satel.violated_zones:
self._state = 1
else:
self._state = 0
self.async_on_remove( self.async_on_remove(
async_dispatcher_connect( async_dispatcher_connect(
self.hass, self._react_to_signal, self._devices_updated self.hass, self._react_to_signal, self._devices_updated
) )
) )
@property
def name(self):
"""Return the name of the entity."""
return self._name
@property
def icon(self) -> str | None:
"""Icon for device by its type."""
if self._zone_type is BinarySensorDeviceClass.SMOKE:
return "mdi:fire"
return None
@property
def is_on(self):
"""Return true if sensor is on."""
return self._state == 1
@property
def device_class(self):
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type
@callback @callback
def _devices_updated(self, zones): def _devices_updated(self, zones: dict[int, int]):
"""Update the zone's state, if needed.""" """Update the zone's state, if needed."""
if self._device_number in zones and self._state != zones[self._device_number]: if self._device_number in zones:
self._state = zones[self._device_number] new_state = zones[self._device_number] == 1
if new_state != self._attr_is_on:
self._attr_is_on = new_state
self.async_write_ha_state() self.async_write_ha_state()

View File

@@ -12,6 +12,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.number import ( from homeassistant.components.number import (
DOMAIN as NUMBER_PLATFORM, DOMAIN as NUMBER_PLATFORM,
NumberDeviceClass,
NumberEntity, NumberEntity,
NumberEntityDescription, NumberEntityDescription,
NumberExtraStoredData, NumberExtraStoredData,
@@ -107,6 +108,9 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
if description.mode_fn is not None: if description.mode_fn is not None:
self._attr_mode = description.mode_fn(coordinator.device.config[key]) self._attr_mode = description.mode_fn(coordinator.device.config[key])
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
delattr(self, "_attr_name")
@property @property
def native_value(self) -> float | None: def native_value(self) -> float | None:
"""Return value of number.""" """Return value of number."""
@@ -181,7 +185,6 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
("device", "valvePos"): BlockNumberDescription( ("device", "valvePos"): BlockNumberDescription(
key="device|valvepos", key="device|valvepos",
translation_key="valve_position", translation_key="valve_position",
name="Valve position",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
available=lambda block: cast(int, block.valveError) != 1, available=lambda block: cast(int, block.valveError) != 1,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
@@ -200,12 +203,12 @@ RPC_NUMBERS: Final = {
key="blutrv", key="blutrv",
sub_key="current_C", sub_key="current_C",
translation_key="external_temperature", translation_key="external_temperature",
name="External temperature",
native_min_value=-50, native_min_value=-50,
native_max_value=50, native_max_value=50,
native_step=0.1, native_step=0.1,
mode=NumberMode.BOX, mode=NumberMode.BOX,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
method="blu_trv_set_external_temperature", method="blu_trv_set_external_temperature",
entity_class=RpcBluTrvExtTempNumber, entity_class=RpcBluTrvExtTempNumber,
@@ -213,7 +216,7 @@ RPC_NUMBERS: Final = {
"number_generic": RpcNumberDescription( "number_generic": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
removal_condition=lambda config, _status, key: not is_view_for_platform( removal_condition=lambda config, _, key: not is_view_for_platform(
config, key, NUMBER_PLATFORM config, key, NUMBER_PLATFORM
), ),
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
@@ -229,9 +232,11 @@ RPC_NUMBERS: Final = {
"number_current_limit": RpcNumberDescription( "number_current_limit": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
translation_key="current_limit",
device_class=NumberDeviceClass.CURRENT,
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"], min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER, mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"), step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit, unit=get_virtual_component_unit,
method="number_set", method="number_set",
@@ -241,10 +246,11 @@ RPC_NUMBERS: Final = {
"number_position": RpcNumberDescription( "number_position": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
translation_key="valve_position",
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"], min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER, mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"), step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit, unit=get_virtual_component_unit,
method="number_set", method="number_set",
@@ -254,10 +260,12 @@ RPC_NUMBERS: Final = {
"number_target_humidity": RpcNumberDescription( "number_target_humidity": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
translation_key="target_humidity",
device_class=NumberDeviceClass.HUMIDITY,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"], min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER, mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"), step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit, unit=get_virtual_component_unit,
method="number_set", method="number_set",
@@ -267,10 +275,12 @@ RPC_NUMBERS: Final = {
"number_target_temperature": RpcNumberDescription( "number_target_temperature": RpcNumberDescription(
key="number", key="number",
sub_key="value", sub_key="value",
translation_key="target_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
max_fn=lambda config: config["max"], max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"], min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER, mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"), step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit, unit=get_virtual_component_unit,
method="number_set", method="number_set",
@@ -281,21 +291,20 @@ RPC_NUMBERS: Final = {
key="blutrv", key="blutrv",
sub_key="pos", sub_key="pos",
translation_key="valve_position", translation_key="valve_position",
name="Valve position",
native_min_value=0, native_min_value=0,
native_max_value=100, native_max_value=100,
native_step=1, native_step=1,
mode=NumberMode.SLIDER, mode=NumberMode.SLIDER,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
method="blu_trv_set_valve_position", method="blu_trv_set_valve_position",
removal_condition=lambda config, _status, key: config[key].get("enable", True) removal_condition=lambda config, _, key: config[key].get("enable", True)
is True, is True,
entity_class=RpcBluTrvNumber, entity_class=RpcBluTrvNumber,
), ),
"left_slot_intensity": RpcNumberDescription( "left_slot_intensity": RpcNumberDescription(
key="cury", key="cury",
sub_key="slots", sub_key="slots",
name="Left slot intensity", translation_key="left_slot_intensity",
value=lambda status, _: status["left"]["intensity"], value=lambda status, _: status["left"]["intensity"],
native_min_value=0, native_min_value=0,
native_max_value=100, native_max_value=100,
@@ -311,7 +320,7 @@ RPC_NUMBERS: Final = {
"right_slot_intensity": RpcNumberDescription( "right_slot_intensity": RpcNumberDescription(
key="cury", key="cury",
sub_key="slots", sub_key="slots",
name="Right slot intensity", translation_key="right_slot_intensity",
value=lambda status, _: status["right"]["intensity"], value=lambda status, _: status["right"]["intensity"],
native_min_value=0, native_min_value=0,
native_max_value=100, native_max_value=100,
@@ -402,6 +411,9 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
self.restored_data: NumberExtraStoredData | None = None self.restored_data: NumberExtraStoredData | None = None
super().__init__(coordinator, block, attribute, description, entry) super().__init__(coordinator, block, attribute, description, entry)
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Handle entity which will be added.""" """Handle entity which will be added."""
await super().async_added_to_hass() await super().async_added_to_hass()

View File

@@ -188,6 +188,29 @@
} }
} }
}, },
"number": {
"current_limit": {
"name": "Current limit"
},
"external_temperature": {
"name": "External temperature"
},
"left_slot_intensity": {
"name": "Left slot intensity"
},
"right_slot_intensity": {
"name": "Right slot intensity"
},
"target_humidity": {
"name": "Target humidity"
},
"target_temperature": {
"name": "Target temperature"
},
"valve_position": {
"name": "Valve position"
}
},
"select": { "select": {
"cury_mode": { "cury_mode": {
"name": "Mode", "name": "Mode",

View File

@@ -30,5 +30,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pysmartthings"], "loggers": ["pysmartthings"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pysmartthings==3.3.1"] "requirements": ["pysmartthings==3.3.2"]
} }

View File

@@ -41,5 +41,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["switchbot"], "loggers": ["switchbot"],
"quality_scale": "gold", "quality_scale": "gold",
"requirements": ["PySwitchbot==0.72.1"] "requirements": ["PySwitchbot==0.73.0"]
} }

View File

@@ -84,6 +84,7 @@
"abort": { "abort": {
"already_configured": "Chat already configured" "already_configured": "Chat already configured"
}, },
"entry_type": "Allowed chat ID",
"error": { "error": {
"chat_not_found": "Chat not found" "chat_not_found": "Chat not found"
}, },

View File

@@ -19,9 +19,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity from .entity import TuyaEntity
from .models import EnumTypeData, find_dpcode from .models import DPCodeEnumWrapper
from .util import get_dpcode from .util import get_dpcode
@@ -85,9 +85,21 @@ async def async_setup_entry(
device = manager.device_map[device_id] device = manager.device_map[device_id]
if descriptions := ALARM.get(device.category): if descriptions := ALARM.get(device.category):
entities.extend( entities.extend(
TuyaAlarmEntity(device, manager, description) TuyaAlarmEntity(
device,
manager,
description,
action_dpcode_wrapper=action_dpcode_wrapper,
state_dpcode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, description.master_state
),
)
for description in descriptions for description in descriptions
if description.key in device.status if (
action_dpcode_wrapper := DPCodeEnumWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
) )
async_add_entities(entities) async_add_entities(entities)
@@ -103,7 +115,6 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
_attr_name = None _attr_name = None
_attr_code_arm_required = False _attr_code_arm_required = False
_master_state: EnumTypeData | None = None
_alarm_msg_dpcode: DPCode | None = None _alarm_msg_dpcode: DPCode | None = None
def __init__( def __init__(
@@ -111,34 +122,25 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
device: CustomerDevice, device: CustomerDevice,
device_manager: Manager, device_manager: Manager,
description: TuyaAlarmControlPanelEntityDescription, description: TuyaAlarmControlPanelEntityDescription,
*,
action_dpcode_wrapper: DPCodeEnumWrapper,
state_dpcode_wrapper: DPCodeEnumWrapper | None,
) -> None: ) -> None:
"""Init Tuya Alarm.""" """Init Tuya Alarm."""
super().__init__(device, device_manager) super().__init__(device, device_manager)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}" self._attr_unique_id = f"{super().unique_id}{description.key}"
self._action_dpcode_wrapper = action_dpcode_wrapper
self._state_dpcode_wrapper = state_dpcode_wrapper
# Determine supported modes # Determine supported modes
if supported_modes := find_dpcode( if Mode.HOME in action_dpcode_wrapper.type_information.range:
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
):
if Mode.HOME in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if Mode.ARM in action_dpcode_wrapper.type_information.range:
if Mode.ARM in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if Mode.SOS in action_dpcode_wrapper.type_information.range:
if Mode.SOS in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
# Determine master state
if enum_type := find_dpcode(
self.device,
description.master_state,
dptype=DPType.ENUM,
prefer_function=True,
):
self._master_state = enum_type
# Determine alarm message # Determine alarm message
if dp_code := get_dpcode(self.device, description.alarm_msg): if dp_code := get_dpcode(self.device, description.alarm_msg):
self._alarm_msg_dpcode = dp_code self._alarm_msg_dpcode = dp_code
@@ -149,8 +151,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'. # When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
# The 'mode' doesn't change, and stays as 'arm' or 'home'. # The 'mode' doesn't change, and stays as 'arm' or 'home'.
if ( if (
self._master_state is not None self._state_dpcode_wrapper is not None
and self.device.status.get(self._master_state.dpcode) == State.ALARM and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
): ):
# Only report as triggered if NOT a battery warning # Only report as triggered if NOT a battery warning
if ( if (
@@ -166,28 +168,26 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
def changed_by(self) -> str | None: def changed_by(self) -> str | None:
"""Last change triggered by.""" """Last change triggered by."""
if ( if (
self._master_state is not None self._state_dpcode_wrapper is not None
and self._alarm_msg_dpcode is not None and self._alarm_msg_dpcode is not None
and self.device.status.get(self._master_state.dpcode) == State.ALARM and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode)) and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode))
): ):
return b64decode(encoded_msg).decode("utf-16be") return b64decode(encoded_msg).decode("utf-16be")
return None return None
def alarm_disarm(self, code: str | None = None) -> None: async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command.""" """Send Disarm command."""
self._send_command( await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.DISARMED)
[{"code": self.entity_description.key, "value": Mode.DISARMED}]
)
def alarm_arm_home(self, code: str | None = None) -> None: async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command.""" """Send Home command."""
self._send_command([{"code": self.entity_description.key, "value": Mode.HOME}]) await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.HOME)
def alarm_arm_away(self, code: str | None = None) -> None: async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command.""" """Send Arm command."""
self._send_command([{"code": self.entity_description.key, "value": Mode.ARM}]) await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.ARM)
def alarm_trigger(self, code: str | None = None) -> None: async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command.""" """Send SOS command."""
self._send_command([{"code": self.entity_description.key, "value": Mode.SOS}]) await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.SOS)

View File

@@ -15,11 +15,11 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from . import TuyaConfigEntry from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity from .entity import TuyaEntity
from .models import DPCodeBitmapBitWrapper, DPCodeBooleanWrapper, DPCodeWrapper
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -366,20 +366,48 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
} }
def _get_bitmap_bit_mask( class _CustomDPCodeWrapper(DPCodeWrapper):
device: CustomerDevice, dpcode: str, bitmap_key: str | None """Custom DPCode Wrapper to check for values in a set."""
) -> int | None:
"""Get the bit mask for a given bitmap description.""" _valid_values: set[bool | float | int | str]
if (
bitmap_key is None def __init__(
or (status_range := device.status_range.get(dpcode)) is None self, dpcode: str, valid_values: set[bool | float | int | str]
or status_range.type != DPType.BITMAP ) -> None:
or not isinstance(bitmap_values := json_loads(status_range.values), dict) """Init CustomDPCodeBooleanWrapper."""
or not isinstance(bitmap_labels := bitmap_values.get("label"), list) super().__init__(dpcode)
or bitmap_key not in bitmap_labels self._valid_values = valid_values
):
def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) is None:
return None return None
return bitmap_labels.index(bitmap_key) return raw_value in self._valid_values
def _get_dpcode_wrapper(
device: CustomerDevice,
description: TuyaBinarySensorEntityDescription,
) -> DPCodeWrapper | None:
"""Get DPCode wrapper for an entity description."""
dpcode = description.dpcode or description.key
if description.bitmap_key is not None:
return DPCodeBitmapBitWrapper.find_dpcode(
device, dpcode, bitmap_key=description.bitmap_key
)
if bool_type := DPCodeBooleanWrapper.find_dpcode(device, dpcode):
return bool_type
# Legacy / compatibility
if dpcode not in device.status:
return None
return _CustomDPCodeWrapper(
dpcode,
description.on_value
if isinstance(description.on_value, set)
else {description.on_value},
)
async def async_setup_entry( async def async_setup_entry(
@@ -397,24 +425,10 @@ async def async_setup_entry(
for device_id in device_ids: for device_id in device_ids:
device = manager.device_map[device_id] device = manager.device_map[device_id]
if descriptions := BINARY_SENSORS.get(device.category): if descriptions := BINARY_SENSORS.get(device.category):
for description in descriptions: entities.extend(
dpcode = description.dpcode or description.key TuyaBinarySensorEntity(device, manager, description, dpcode_wrapper)
if dpcode in device.status: for description in descriptions
mask = _get_bitmap_bit_mask( if (dpcode_wrapper := _get_dpcode_wrapper(device, description))
device, dpcode, description.bitmap_key
)
if (
description.bitmap_key is None # Regular binary sensor
or mask is not None # Bitmap sensor with valid mask
):
entities.append(
TuyaBinarySensorEntity(
device,
manager,
description,
mask,
)
) )
async_add_entities(entities) async_add_entities(entities)
@@ -436,26 +450,15 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
device: CustomerDevice, device: CustomerDevice,
device_manager: Manager, device_manager: Manager,
description: TuyaBinarySensorEntityDescription, description: TuyaBinarySensorEntityDescription,
bit_mask: int | None = None, dpcode_wrapper: DPCodeWrapper,
) -> None: ) -> None:
"""Init Tuya binary sensor.""" """Init Tuya binary sensor."""
super().__init__(device, device_manager) super().__init__(device, device_manager)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}" self._attr_unique_id = f"{super().unique_id}{description.key}"
self._bit_mask = bit_mask self._dpcode_wrapper = dpcode_wrapper
@property @property
def is_on(self) -> bool: def is_on(self) -> bool | None:
"""Return true if sensor is on.""" """Return true if sensor is on."""
dpcode = self.entity_description.dpcode or self.entity_description.key return self._dpcode_wrapper.read_device_status(self.device)
if dpcode not in self.device.status:
return False
if self._bit_mask is not None:
# For bitmap sensors, check the specific bit mask
return (self.device.status[dpcode] & (1 << self._bit_mask)) != 0
if isinstance(self.entity_description.on_value, set):
return self.device.status[dpcode] in self.entity_description.on_value
return self.device.status[dpcode] == self.entity_description.on_value

View File

@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper
BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = { BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
DeviceCategory.HXD: ( DeviceCategory.HXD: (
@@ -21,6 +22,19 @@ BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
translation_key="snooze", translation_key="snooze",
), ),
), ),
DeviceCategory.MSP: (
ButtonEntityDescription(
key=DPCode.FACTORY_RESET,
translation_key="factory_reset",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
ButtonEntityDescription(
key=DPCode.MANUAL_CLEAN,
translation_key="manual_clean",
entity_category=EntityCategory.CONFIG,
),
),
DeviceCategory.SD: ( DeviceCategory.SD: (
ButtonEntityDescription( ButtonEntityDescription(
key=DPCode.RESET_DUSTER_CLOTH, key=DPCode.RESET_DUSTER_CLOTH,
@@ -67,9 +81,13 @@ async def async_setup_entry(
device = manager.device_map[device_id] device = manager.device_map[device_id]
if descriptions := BUTTONS.get(device.category): if descriptions := BUTTONS.get(device.category):
entities.extend( entities.extend(
TuyaButtonEntity(device, manager, description) TuyaButtonEntity(device, manager, description, dpcode_wrapper)
for description in descriptions for description in descriptions
if description.key in device.status if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
) )
async_add_entities(entities) async_add_entities(entities)
@@ -89,12 +107,14 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity):
device: CustomerDevice, device: CustomerDevice,
device_manager: Manager, device_manager: Manager,
description: ButtonEntityDescription, description: ButtonEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
) -> None: ) -> None:
"""Init Tuya button.""" """Init Tuya button."""
super().__init__(device, device_manager) super().__init__(device, device_manager)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}" self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
def press(self) -> None: async def async_press(self) -> None:
"""Press the button.""" """Press the button."""
self._send_command([{"code": self.entity_description.key, "value": True}]) await self._async_send_dpcode_update(self._dpcode_wrapper, True)

View File

@@ -704,6 +704,7 @@ class DPCode(StrEnum):
DECIBEL_SWITCH = "decibel_switch" DECIBEL_SWITCH = "decibel_switch"
DEHUMIDITY_SET_ENUM = "dehumidify_set_enum" DEHUMIDITY_SET_ENUM = "dehumidify_set_enum"
DEHUMIDITY_SET_VALUE = "dehumidify_set_value" DEHUMIDITY_SET_VALUE = "dehumidify_set_value"
DELAY_CLEAN_TIME = "delay_clean_time"
DELAY_SET = "delay_set" DELAY_SET = "delay_set"
DEW_POINT_TEMP = "dew_point_temp" DEW_POINT_TEMP = "dew_point_temp"
DISINFECTION = "disinfection" DISINFECTION = "disinfection"
@@ -717,6 +718,7 @@ class DPCode(StrEnum):
ELECTRICITY_LEFT = "electricity_left" ELECTRICITY_LEFT = "electricity_left"
EXCRETION_TIME_DAY = "excretion_time_day" EXCRETION_TIME_DAY = "excretion_time_day"
EXCRETION_TIMES_DAY = "excretion_times_day" EXCRETION_TIMES_DAY = "excretion_times_day"
FACTORY_RESET = "factory_reset"
FAN_BEEP = "fan_beep" # Sound FAN_BEEP = "fan_beep" # Sound
FAN_COOL = "fan_cool" # Cool wind FAN_COOL = "fan_cool" # Cool wind
FAN_DIRECTION = "fan_direction" # Fan direction FAN_DIRECTION = "fan_direction" # Fan direction
@@ -773,6 +775,7 @@ class DPCode(StrEnum):
LIQUID_STATE = "liquid_state" LIQUID_STATE = "liquid_state"
LOCK = "lock" # Lock / Child lock LOCK = "lock" # Lock / Child lock
MACH_OPERATE = "mach_operate" MACH_OPERATE = "mach_operate"
MANUAL_CLEAN = "manual_clean"
MANUAL_FEED = "manual_feed" MANUAL_FEED = "manual_feed"
MASTER_MODE = "master_mode" # alarm mode MASTER_MODE = "master_mode" # alarm mode
MASTER_STATE = "master_state" # alarm state MASTER_STATE = "master_state" # alarm state

View File

@@ -240,6 +240,13 @@ LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA, color_data=DPCode.COLOUR_DATA,
), ),
), ),
DeviceCategory.MSP: (
TuyaLightEntityDescription(
key=DPCode.LIGHT,
translation_key="light",
entity_category=EntityCategory.CONFIG,
),
),
DeviceCategory.QJDCZ: ( DeviceCategory.QJDCZ: (
TuyaLightEntityDescription( TuyaLightEntityDescription(
key=DPCode.SWITCH_LED, key=DPCode.SWITCH_LED,

View File

@@ -22,17 +22,18 @@ class TypeInformation:
As provided by the SDK, from `device.function` / `device.status_range`. As provided by the SDK, from `device.function` / `device.status_range`.
""" """
dpcode: DPCode
@classmethod @classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None: def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a TypeInformation object.""" """Load JSON string and return a TypeInformation object."""
raise NotImplementedError("from_json is not implemented for this type") return cls(dpcode)
@dataclass @dataclass
class IntegerTypeData(TypeInformation): class IntegerTypeData(TypeInformation):
"""Integer Type Data.""" """Integer Type Data."""
dpcode: DPCode
min: int min: int
max: int max: int
scale: float scale: float
@@ -100,11 +101,24 @@ class IntegerTypeData(TypeInformation):
) )
@dataclass
class BitmapTypeInformation(TypeInformation):
"""Bitmap type information."""
label: list[str]
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a BitmapTypeInformation object."""
if not (parsed := json.loads(data)):
return None
return cls(dpcode, **parsed)
@dataclass @dataclass
class EnumTypeData(TypeInformation): class EnumTypeData(TypeInformation):
"""Enum Type Data.""" """Enum Type Data."""
dpcode: DPCode
range: list[str] range: list[str]
@classmethod @classmethod
@@ -116,6 +130,8 @@ class EnumTypeData(TypeInformation):
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = { _TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
DPType.BITMAP: BitmapTypeInformation,
DPType.BOOLEAN: TypeInformation,
DPType.ENUM: EnumTypeData, DPType.ENUM: EnumTypeData,
DPType.INTEGER: IntegerTypeData, DPType.INTEGER: IntegerTypeData,
} }
@@ -146,13 +162,13 @@ class DPCodeWrapper(ABC):
The raw device status is converted to a Home Assistant value. The raw device status is converted to a Home Assistant value.
""" """
@abstractmethod
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any: def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value. """Convert a Home Assistant value back to a raw device value.
This is called by `get_update_command` to prepare the value for sending This is called by `get_update_command` to prepare the value for sending
back to the device, and should be implemented in concrete classes. back to the device, and should be implemented in concrete classes if needed.
""" """
raise NotImplementedError
def get_update_command(self, device: CustomerDevice, value: Any) -> dict[str, Any]: def get_update_command(self, device: CustomerDevice, value: Any) -> dict[str, Any]:
"""Get the update command for the dpcode. """Get the update command for the dpcode.
@@ -165,29 +181,6 @@ class DPCodeWrapper(ABC):
} }
class DPCodeBooleanWrapper(DPCodeWrapper):
"""Simple wrapper for boolean values.
Supports True/False only.
"""
def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) in (True, False):
return raw_value
return None
def _convert_value_to_raw_value(
self, device: CustomerDevice, value: Any
) -> Any | None:
"""Convert a Home Assistant value back to a raw device value."""
if value in (True, False):
return value
# Currently only called with boolean values
# Safety net in case of future changes
raise ValueError(f"Invalid boolean value `{value}`")
class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper): class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
"""Base DPCode wrapper with Type Information.""" """Base DPCode wrapper with Type Information."""
@@ -203,7 +196,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
def find_dpcode( def find_dpcode(
cls, cls,
device: CustomerDevice, device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...], dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*, *,
prefer_function: bool = False, prefer_function: bool = False,
) -> Self | None: ) -> Self | None:
@@ -217,6 +210,31 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
return None return None
class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
"""Simple wrapper for boolean values.
Supports True/False only.
"""
DPTYPE = DPType.BOOLEAN
def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) in (True, False):
return raw_value
return None
def _convert_value_to_raw_value(
self, device: CustomerDevice, value: Any
) -> Any | None:
"""Convert a Home Assistant value back to a raw device value."""
if value in (True, False):
return value
# Currently only called with boolean values
# Safety net in case of future changes
raise ValueError(f"Invalid boolean value `{value}`")
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]): class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
"""Simple wrapper for EnumTypeData values.""" """Simple wrapper for EnumTypeData values."""
@@ -272,6 +290,48 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
) )
class DPCodeBitmapBitWrapper(DPCodeWrapper):
"""Simple wrapper for a specific bit in bitmap values."""
def __init__(self, dpcode: str, mask: int) -> None:
"""Init DPCodeBitmapWrapper."""
super().__init__(dpcode)
self._mask = mask
def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) is None:
return None
return (raw_value & (1 << self._mask)) != 0
@classmethod
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...],
*,
bitmap_key: str,
) -> Self | None:
"""Find and return a DPCodeBitmapBitWrapper for the given DP codes."""
if (
type_information := find_dpcode(device, dpcodes, dptype=DPType.BITMAP)
) and bitmap_key in type_information.label:
return cls(
type_information.dpcode, type_information.label.index(bitmap_key)
)
return None
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.BITMAP],
) -> BitmapTypeInformation | None: ...
@overload @overload
def find_dpcode( def find_dpcode(
device: CustomerDevice, device: CustomerDevice,

View File

@@ -180,6 +180,14 @@ NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
), ),
DeviceCategory.MSP: (
NumberEntityDescription(
key=DPCode.DELAY_CLEAN_TIME,
translation_key="delay_clean_time",
device_class=NumberDeviceClass.DURATION,
entity_category=EntityCategory.CONFIG,
),
),
DeviceCategory.MZJ: ( DeviceCategory.MZJ: (
NumberEntityDescription( NumberEntityDescription(
key=DPCode.COOK_TEMPERATURE, key=DPCode.COOK_TEMPERATURE,

View File

@@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper
SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = { SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = {
DeviceCategory.CO2BJ: ( DeviceCategory.CO2BJ: (
@@ -64,9 +65,13 @@ async def async_setup_entry(
device = manager.device_map[device_id] device = manager.device_map[device_id]
if descriptions := SIRENS.get(device.category): if descriptions := SIRENS.get(device.category):
entities.extend( entities.extend(
TuyaSirenEntity(device, manager, description) TuyaSirenEntity(device, manager, description, dpcode_wrapper)
for description in descriptions for description in descriptions
if description.key in device.status if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
) )
async_add_entities(entities) async_add_entities(entities)
@@ -89,21 +94,23 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
device: CustomerDevice, device: CustomerDevice,
device_manager: Manager, device_manager: Manager,
description: SirenEntityDescription, description: SirenEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
) -> None: ) -> None:
"""Init Tuya Siren.""" """Init Tuya Siren."""
super().__init__(device, device_manager) super().__init__(device, device_manager)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}" self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
@property @property
def is_on(self) -> bool: def is_on(self) -> bool | None:
"""Return true if siren is on.""" """Return true if siren is on."""
return self.device.status.get(self.entity_description.key, False) return self._dpcode_wrapper.read_device_status(self.device)
def turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on.""" """Turn the siren on."""
self._send_command([{"code": self.entity_description.key, "value": True}]) await self._async_send_dpcode_update(self._dpcode_wrapper, True)
def turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the siren off.""" """Turn the siren off."""
self._send_command([{"code": self.entity_description.key, "value": False}]) await self._async_send_dpcode_update(self._dpcode_wrapper, False)

View File

@@ -77,6 +77,12 @@
} }
}, },
"button": { "button": {
"factory_reset": {
"name": "Factory reset"
},
"manual_clean": {
"name": "Manual clean"
},
"reset_duster_cloth": { "reset_duster_cloth": {
"name": "Reset duster cloth" "name": "Reset duster cloth"
}, },
@@ -166,6 +172,9 @@
"cook_time": { "cook_time": {
"name": "Cooking time" "name": "Cooking time"
}, },
"delay_clean_time": {
"name": "Delay clean time"
},
"down_delay": { "down_delay": {
"name": "Down delay" "name": "Down delay"
}, },

View File

@@ -946,14 +946,13 @@ async def async_setup_entry(
device = manager.device_map[device_id] device = manager.device_map[device_id]
if descriptions := SWITCHES.get(device.category): if descriptions := SWITCHES.get(device.category):
entities.extend( entities.extend(
TuyaSwitchEntity( TuyaSwitchEntity(device, manager, description, dpcode_wrapper)
device,
manager,
description,
DPCodeBooleanWrapper(description.key),
)
for description in descriptions for description in descriptions
if description.key in device.status if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
and _check_deprecation( and _check_deprecation(
hass, hass,
device, device,

View File

@@ -94,14 +94,13 @@ async def async_setup_entry(
device = manager.device_map[device_id] device = manager.device_map[device_id]
if descriptions := VALVES.get(device.category): if descriptions := VALVES.get(device.category):
entities.extend( entities.extend(
TuyaValveEntity( TuyaValveEntity(device, manager, description, dpcode_wrapper)
device,
manager,
description,
DPCodeBooleanWrapper(description.key),
)
for description in descriptions for description in descriptions
if description.key in device.status if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
) )
async_add_entities(entities) async_add_entities(entities)

View File

@@ -14,7 +14,7 @@
"velbus-protocol" "velbus-protocol"
], ],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["velbus-aio==2025.8.0"], "requirements": ["velbus-aio==2025.11.0"],
"usb": [ "usb": [
{ {
"pid": "0B1B", "pid": "0B1B",

View File

@@ -1,17 +1,20 @@
"""Support for VELUX KLF 200 devices.""" """Support for VELUX KLF 200 devices."""
from __future__ import annotations
from pyvlx import PyVLX, PyVLXException from pyvlx import PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, LOGGER, PLATFORMS from .const import DOMAIN, LOGGER, PLATFORMS
type VeluxConfigEntry = ConfigEntry[PyVLX] type VeluxConfigEntry = ConfigEntry[PyVLX]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Set up the velux component.""" """Set up the velux component."""
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
password = entry.data[CONF_PASSWORD] password = entry.data[CONF_PASSWORD]
@@ -27,6 +30,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.runtime_data = pyvlx entry.runtime_data = pyvlx
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"gateway_{entry.entry_id}")},
name="KLF 200 Gateway",
manufacturer="Velux",
model="KLF 200",
hw_version=(
str(pyvlx.klf200.version.hardwareversion) if pyvlx.klf200.version else None
),
sw_version=(
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
),
)
async def on_hass_stop(event): async def on_hass_stop(event):
"""Close connection when hass stops.""" """Close connection when hass stops."""
LOGGER.debug("Velux interface terminated") LOGGER.debug("Velux interface terminated")
@@ -46,6 +64,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -24,14 +24,14 @@ SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: VeluxConfigEntry, config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up rain sensor(s) for Velux platform.""" """Set up rain sensor(s) for Velux platform."""
pyvlx = config.runtime_data pyvlx = config_entry.runtime_data
async_add_entities( async_add_entities(
VeluxRainSensor(node, config.entry_id) VeluxRainSensor(node, config_entry.entry_id)
for node in pyvlx.nodes for node in pyvlx.nodes
if isinstance(node, Window) and node.rain_sensor if isinstance(node, Window) and node.rain_sensor
) )

View File

@@ -32,13 +32,13 @@ PARALLEL_UPDATES = 1
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: VeluxConfigEntry, config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up cover(s) for Velux platform.""" """Set up cover(s) for Velux platform."""
pyvlx = config.runtime_data pyvlx = config_entry.runtime_data
async_add_entities( async_add_entities(
VeluxCover(node, config.entry_id) VeluxCover(node, config_entry.entry_id)
for node in pyvlx.nodes for node in pyvlx.nodes
if isinstance(node, OpeningDevice) if isinstance(node, OpeningDevice)
) )

View File

@@ -18,22 +18,23 @@ class VeluxEntity(Entity):
def __init__(self, node: Node, config_entry_id: str) -> None: def __init__(self, node: Node, config_entry_id: str) -> None:
"""Initialize the Velux device.""" """Initialize the Velux device."""
self.node = node self.node = node
self._attr_unique_id = ( unique_id = (
node.serial_number node.serial_number
if node.serial_number if node.serial_number
else f"{config_entry_id}_{node.node_id}" else f"{config_entry_id}_{node.node_id}"
) )
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={ identifiers={
( (
DOMAIN, DOMAIN,
node.serial_number unique_id,
if node.serial_number
else f"{config_entry_id}_{node.node_id}",
) )
}, },
name=node.name if node.name else f"#{node.node_id}", name=node.name if node.name else f"#{node.node_id}",
serial_number=node.serial_number, serial_number=node.serial_number,
via_device=(DOMAIN, f"gateway_{config_entry_id}"),
) )
@callback @callback

View File

@@ -18,13 +18,13 @@ PARALLEL_UPDATES = 1
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: VeluxConfigEntry, config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up light(s) for Velux platform.""" """Set up light(s) for Velux platform."""
pyvlx = config.runtime_data pyvlx = config_entry.runtime_data
async_add_entities( async_add_entities(
VeluxLight(node, config.entry_id) VeluxLight(node, config_entry.entry_id)
for node in pyvlx.nodes for node in pyvlx.nodes
if isinstance(node, LighteningDevice) if isinstance(node, LighteningDevice)
) )
@@ -35,6 +35,7 @@ class VeluxLight(VeluxEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_color_mode = ColorMode.BRIGHTNESS _attr_color_mode = ColorMode.BRIGHTNESS
_attr_name = None
node: LighteningDevice node: LighteningDevice

View File

@@ -15,11 +15,11 @@ PARALLEL_UPDATES = 1
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: VeluxConfigEntry, config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the scenes for Velux platform.""" """Set up the scenes for Velux platform."""
pyvlx = config.runtime_data pyvlx = config_entry.runtime_data
entities = [VeluxScene(scene) for scene in pyvlx.scenes] entities = [VeluxScene(scene) for scene in pyvlx.scenes]
async_add_entities(entities) async_add_entities(entities)

View File

@@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, VS_MANAGER from .const import DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, VS_MANAGER
@@ -121,3 +122,21 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
hass.config_entries.async_update_entry(config_entry, minor_version=2) hass.config_entries.async_update_entry(config_entry, minor_version=2)
return True return True
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
manager = hass.data[DOMAIN][VS_MANAGER]
await manager.get_devices()
for dev in manager.devices:
if isinstance(dev.sub_device_no, int):
device_id = f"{dev.cid}{dev.sub_device_no!s}"
else:
device_id = dev.cid
identifier = next(iter(device_entry.identifiers), None)
if identifier and device_id == identifier[1]:
return False
return True

View File

@@ -22,6 +22,7 @@ from homeassistant.const import (
UnitOfElectricPotential, UnitOfElectricPotential,
UnitOfEnergy, UnitOfEnergy,
UnitOfPower, UnitOfPower,
UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -139,6 +140,15 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
value_fn=lambda device: device.state.humidity, value_fn=lambda device: device.state.humidity,
exists_fn=is_humidifier, exists_fn=is_humidifier,
), ),
VeSyncSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda device: device.state.temperature,
exists_fn=lambda device: is_humidifier(device)
and device.state.temperature is not None,
),
) )

View File

@@ -58,6 +58,7 @@ from .utils import (
get_compressors, get_compressors,
get_device_serial, get_device_serial,
is_supported, is_supported,
normalize_state,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -1086,7 +1087,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
ViCareSensorEntityDescription( ViCareSensorEntityDescription(
key="compressor_phase", key="compressor_phase",
translation_key="compressor_phase", translation_key="compressor_phase",
value_getter=lambda api: api.getPhase(), value_getter=lambda api: normalize_state(api.getPhase()),
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
), ),
) )

View File

@@ -213,7 +213,18 @@
"name": "Compressor hours load class 5" "name": "Compressor hours load class 5"
}, },
"compressor_phase": { "compressor_phase": {
"name": "Compressor phase" "name": "Compressor phase",
"state": {
"cooling": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]",
"defrost": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::defrosting%]",
"heating": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::heating%]",
"off": "[%key:common::state::off%]",
"passive_defrost": "Passive defrosting",
"pause": "[%key:common::state::idle%]",
"preparing": "Preparing",
"preparing_defrost": "Preparing defrost",
"ready": "[%key:common::state::idle%]"
}
}, },
"compressor_starts": { "compressor_starts": {
"name": "Compressor starts" "name": "Compressor starts"

View File

@@ -133,3 +133,8 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone
def filter_state(state: str) -> str | None: def filter_state(state: str) -> str | None:
"""Return the state if not 'nothing' or 'unknown'.""" """Return the state if not 'nothing' or 'unknown'."""
return None if state in ("nothing", "unknown") else state return None if state in ("nothing", "unknown") else state
def normalize_state(state: str) -> str:
"""Return the state with underscores instead of hyphens."""
return state.replace("-", "_")

View File

@@ -16,6 +16,7 @@ from homeassistant.exceptions import (
) )
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import ( from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session, OAuth2Session,
async_get_config_entry_implementation, async_get_config_entry_implementation,
) )
@@ -65,7 +66,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bo
async def _async_auth_and_create_api( async def _async_auth_and_create_api(
hass: HomeAssistant, entry: VolvoConfigEntry hass: HomeAssistant, entry: VolvoConfigEntry
) -> VolvoCarsApi: ) -> VolvoCarsApi:
try:
implementation = await async_get_config_entry_implementation(hass, entry) implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
oauth_session = OAuth2Session(hass, entry, implementation) oauth_session = OAuth2Session(hass, entry, implementation)
web_session = async_get_clientsession(hass) web_session = async_get_clientsession(hass)
auth = VolvoAuth(web_session, oauth_session) auth = VolvoAuth(web_session, oauth_session)

View File

@@ -362,6 +362,9 @@
"no_vehicle": { "no_vehicle": {
"message": "Unable to retrieve vehicle details." "message": "Unable to retrieve vehicle details."
}, },
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
},
"unauthorized": { "unauthorized": {
"message": "Authentication failed. {message}" "message": "Authentication failed. {message}"
}, },

View File

@@ -54,7 +54,7 @@ _PING_TIMEOUT: Final = 5
_PING_SEND_DELAY: Final = 2 _PING_SEND_DELAY: Final = 2
_PIPELINE_FINISH_TIMEOUT: Final = 1 _PIPELINE_FINISH_TIMEOUT: Final = 1
_TTS_SAMPLE_RATE: Final = 22050 _TTS_SAMPLE_RATE: Final = 22050
_ANNOUNCE_CHUNK_BYTES: Final = 2048 # 1024 samples _AUDIO_CHUNK_BYTES: Final = 2048 # 1024 samples
_TTS_TIMEOUT_EXTRA: Final = 1.0 _TTS_TIMEOUT_EXTRA: Final = 1.0
# Wyoming stage -> Assist stage # Wyoming stage -> Assist stage
@@ -360,7 +360,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
) )
assert proc.stdout is not None assert proc.stdout is not None
while True: while True:
chunk_bytes = await proc.stdout.read(_ANNOUNCE_CHUNK_BYTES) chunk_bytes = await proc.stdout.read(_AUDIO_CHUNK_BYTES)
if not chunk_bytes: if not chunk_bytes:
break break
@@ -782,17 +782,22 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
assert sample_width is not None assert sample_width is not None
assert sample_channels is not None assert sample_channels is not None
data_chunk_idx = 0
while data_chunk_idx < len(data_chunk):
audio_chunk = AudioChunk( audio_chunk = AudioChunk(
rate=sample_rate, rate=sample_rate,
width=sample_width, width=sample_width,
channels=sample_channels, channels=sample_channels,
audio=data_chunk, audio=data_chunk[
data_chunk_idx : data_chunk_idx + _AUDIO_CHUNK_BYTES
],
timestamp=timestamp, timestamp=timestamp,
) )
await self._client.write_event(audio_chunk.event()) await self._client.write_event(audio_chunk.event())
timestamp += audio_chunk.milliseconds timestamp += audio_chunk.milliseconds
total_seconds += audio_chunk.seconds total_seconds += audio_chunk.seconds
data_chunk_idx += _AUDIO_CHUNK_BYTES
await self._client.write_event(AudioStop(timestamp=timestamp).event()) await self._client.write_event(AudioStop(timestamp=timestamp).event())
_LOGGER.debug("TTS streaming complete") _LOGGER.debug("TTS streaming complete")

View File

@@ -99,7 +99,7 @@
}, },
"exceptions": { "exceptions": {
"oauth2_implementation_unavailable": { "oauth2_implementation_unavailable": {
"message": "OAuth2 implementation temporarily unavailable, will retry" "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}, },
"request_exception": { "request_exception": {
"message": "Failed to connect to Xbox Network" "message": "Failed to connect to Xbox Network"

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiomusiccast"], "loggers": ["aiomusiccast"],
"requirements": ["aiomusiccast==0.14.8"], "requirements": ["aiomusiccast==0.15.0"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "Yamaha Corporation" "manufacturer": "Yamaha Corporation"

View File

@@ -134,7 +134,7 @@
"message": "Config entry not found or not loaded!" "message": "Config entry not found or not loaded!"
}, },
"oauth2_implementation_unavailable": { "oauth2_implementation_unavailable": {
"message": "OAuth2 implementation temporarily unavailable, will retry" "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}, },
"valve_inoperable_currently": { "valve_inoperable_currently": {
"message": "The Valve cannot be operated currently." "message": "The Valve cannot be operated currently."

View File

@@ -1304,7 +1304,11 @@ def issues(hass: HomeAssistant) -> dict[tuple[str, str], dict[str, Any]]:
"""Return all open issues.""" """Return all open issues."""
current_issues = ir.async_get(hass).issues current_issues = ir.async_get(hass).issues
# Use JSON for safe representation # Use JSON for safe representation
return {k: v.to_json() for (k, v) in current_issues.items()} return {
key: issue_entry.to_json()
for (key, issue_entry) in current_issues.items()
if issue_entry.active
}
def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | None: def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | None:

View File

@@ -115,6 +115,11 @@
"turned_on": "{entity_name} turned on" "turned_on": "{entity_name} turned on"
} }
}, },
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
}
},
"generic": { "generic": {
"model": "Model", "model": "Model",
"ui_managed": "Managed via UI" "ui_managed": "Managed via UI"

18
requirements_all.txt generated
View File

@@ -83,7 +83,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.13.1 PySrDaliGateway==0.13.1
# homeassistant.components.switchbot # homeassistant.components.switchbot
PySwitchbot==0.72.1 PySwitchbot==0.73.0
# homeassistant.components.switchmate # homeassistant.components.switchmate
PySwitchmate==0.5.1 PySwitchmate==0.5.1
@@ -315,13 +315,13 @@ aiolookin==1.0.0
aiolyric==2.0.2 aiolyric==2.0.2
# homeassistant.components.mealie # homeassistant.components.mealie
aiomealie==1.0.1 aiomealie==1.1.0
# homeassistant.components.modern_forms # homeassistant.components.modern_forms
aiomodernforms==0.1.8 aiomodernforms==0.1.8
# homeassistant.components.yamaha_musiccast # homeassistant.components.yamaha_musiccast
aiomusiccast==0.14.8 aiomusiccast==0.15.0
# homeassistant.components.nanoleaf # homeassistant.components.nanoleaf
aionanoleaf==0.2.1 aionanoleaf==0.2.1
@@ -354,7 +354,7 @@ aiopulse==0.4.6
aiopurpleair==2025.08.1 aiopurpleair==2025.08.1
# homeassistant.components.hunterdouglas_powerview # homeassistant.components.hunterdouglas_powerview
aiopvapi==3.2.1 aiopvapi==3.3.0
# homeassistant.components.pvpc_hourly_pricing # homeassistant.components.pvpc_hourly_pricing
aiopvpc==4.2.2 aiopvpc==4.2.2
@@ -1719,7 +1719,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14 plexwebsocket==0.0.14
# homeassistant.components.plugwise # homeassistant.components.plugwise
plugwise==1.9.0 plugwise==1.10.0
# homeassistant.components.serial_pm # homeassistant.components.serial_pm
pmsensor==0.4 pmsensor==0.4
@@ -2050,7 +2050,7 @@ pyhaversion==22.8.0
pyheos==1.0.6 pyheos==1.0.6
# homeassistant.components.hive # homeassistant.components.hive
pyhive-integration==1.0.6 pyhive-integration==1.0.7
# homeassistant.components.homematic # homeassistant.components.homematic
pyhomematic==0.1.77 pyhomematic==0.1.77
@@ -2259,7 +2259,7 @@ pyotp==2.9.0
pyoverkiz==1.19.0 pyoverkiz==1.19.0
# homeassistant.components.palazzetti # homeassistant.components.palazzetti
pypalazzetti==0.1.19 pypalazzetti==0.1.20
# homeassistant.components.paperless_ngx # homeassistant.components.paperless_ngx
pypaperless==4.1.1 pypaperless==4.1.1
@@ -2380,7 +2380,7 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2 pysmarlaapi==0.9.2
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartthings==3.3.1 pysmartthings==3.3.2
# homeassistant.components.smarty # homeassistant.components.smarty
pysmarty2==0.10.3 pysmarty2==0.10.3
@@ -3076,7 +3076,7 @@ vegehub==0.1.26
vehicle==2.2.2 vehicle==2.2.2
# homeassistant.components.velbus # homeassistant.components.velbus
velbus-aio==2025.8.0 velbus-aio==2025.11.0
# homeassistant.components.venstar # homeassistant.components.venstar
venstarcolortouch==0.21 venstarcolortouch==0.21

View File

@@ -21,7 +21,7 @@ pydantic==2.12.2
pylint==4.0.1 pylint==4.0.1
pylint-per-file-ignores==1.4.0 pylint-per-file-ignores==1.4.0
pipdeptree==2.26.1 pipdeptree==2.26.1
pytest-asyncio==1.2.0 pytest-asyncio==1.3.0
pytest-aiohttp==1.1.0 pytest-aiohttp==1.1.0
pytest-cov==7.0.0 pytest-cov==7.0.0
pytest-freezer==0.4.9 pytest-freezer==0.4.9

View File

@@ -80,7 +80,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.13.1 PySrDaliGateway==0.13.1
# homeassistant.components.switchbot # homeassistant.components.switchbot
PySwitchbot==0.72.1 PySwitchbot==0.73.0
# homeassistant.components.syncthru # homeassistant.components.syncthru
PySyncThru==0.8.0 PySyncThru==0.8.0
@@ -297,13 +297,13 @@ aiolookin==1.0.0
aiolyric==2.0.2 aiolyric==2.0.2
# homeassistant.components.mealie # homeassistant.components.mealie
aiomealie==1.0.1 aiomealie==1.1.0
# homeassistant.components.modern_forms # homeassistant.components.modern_forms
aiomodernforms==0.1.8 aiomodernforms==0.1.8
# homeassistant.components.yamaha_musiccast # homeassistant.components.yamaha_musiccast
aiomusiccast==0.14.8 aiomusiccast==0.15.0
# homeassistant.components.nanoleaf # homeassistant.components.nanoleaf
aionanoleaf==0.2.1 aionanoleaf==0.2.1
@@ -336,7 +336,7 @@ aiopulse==0.4.6
aiopurpleair==2025.08.1 aiopurpleair==2025.08.1
# homeassistant.components.hunterdouglas_powerview # homeassistant.components.hunterdouglas_powerview
aiopvapi==3.2.1 aiopvapi==3.3.0
# homeassistant.components.pvpc_hourly_pricing # homeassistant.components.pvpc_hourly_pricing
aiopvpc==4.2.2 aiopvpc==4.2.2
@@ -1456,7 +1456,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14 plexwebsocket==0.0.14
# homeassistant.components.plugwise # homeassistant.components.plugwise
plugwise==1.9.0 plugwise==1.10.0
# homeassistant.components.poolsense # homeassistant.components.poolsense
poolsense==0.0.8 poolsense==0.0.8
@@ -1709,7 +1709,7 @@ pyhaversion==22.8.0
pyheos==1.0.6 pyheos==1.0.6
# homeassistant.components.hive # homeassistant.components.hive
pyhive-integration==1.0.6 pyhive-integration==1.0.7
# homeassistant.components.homematic # homeassistant.components.homematic
pyhomematic==0.1.77 pyhomematic==0.1.77
@@ -1885,7 +1885,7 @@ pyotp==2.9.0
pyoverkiz==1.19.0 pyoverkiz==1.19.0
# homeassistant.components.palazzetti # homeassistant.components.palazzetti
pypalazzetti==0.1.19 pypalazzetti==0.1.20
# homeassistant.components.paperless_ngx # homeassistant.components.paperless_ngx
pypaperless==4.1.1 pypaperless==4.1.1
@@ -1982,7 +1982,7 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2 pysmarlaapi==0.9.2
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartthings==3.3.1 pysmartthings==3.3.2
# homeassistant.components.smarty # homeassistant.components.smarty
pysmarty2==0.10.3 pysmarty2==0.10.3
@@ -2543,7 +2543,7 @@ vegehub==0.1.26
vehicle==2.2.2 vehicle==2.2.2
# homeassistant.components.velbus # homeassistant.components.velbus
velbus-aio==2025.8.0 velbus-aio==2025.11.0
# homeassistant.components.venstar # homeassistant.components.venstar
venstarcolortouch==0.21 venstarcolortouch==0.21

View File

@@ -4,7 +4,6 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from homeassistant import core from homeassistant import core
from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION as GO2RTC_VERSION
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.util import executor, thread from homeassistant.util import executor, thread
from script.gen_requirements_all import gather_recursive_requirements from script.gen_requirements_all import gather_recursive_requirements
@@ -12,6 +11,8 @@ from script.gen_requirements_all import gather_recursive_requirements
from .model import Config, Integration from .model import Config, Integration
from .requirements import PACKAGE_REGEX, PIP_VERSION_RANGE_SEPARATOR from .requirements import PACKAGE_REGEX, PIP_VERSION_RANGE_SEPARATOR
_GORTC_DOCKER_SHA = "64ab39fdcf7571075f4ef1a818a1019aa359e2e730600771d265b50a94449532"
DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest. DOCKERFILE_TEMPLATE = r"""# Automatically generated by hassfest.
# #
# To update, run python3 -m script.hassfest -p docker # To update, run python3 -m script.hassfest -p docker
@@ -29,20 +30,10 @@ ARG QEMU_CPU
# Home Assistant S6-Overlay # Home Assistant S6-Overlay
COPY rootfs / COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary # Get go2rtc binary
RUN \ COPY --from=ghcr.io/alexxit/go2rtc@sha256:{go2rtc_sha} /usr/local/bin/go2rtc /bin/go2rtc
case "${{BUILD_ARCH}}" in \ # Verify go2rtc can be executed
"aarch64") go2rtc_suffix='arm64' ;; \ RUN go2rtc --version
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${{BUILD_ARCH}} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
# Install uv # Install uv
RUN pip3 install uv=={uv} RUN pip3 install uv=={uv}
@@ -166,8 +157,6 @@ def _generate_hassfest_dockerimage(
packages.update( packages.update(
gather_recursive_requirements(platform.value, already_checked_domains) gather_recursive_requirements(platform.value, already_checked_domains)
) )
# Add go2rtc requirements as this file needs the go2rtc integration
packages.update(gather_recursive_requirements("go2rtc", already_checked_domains))
return File( return File(
_HASSFEST_TEMPLATE.format( _HASSFEST_TEMPLATE.format(
@@ -203,7 +192,7 @@ def _generate_files(config: Config) -> list[File]:
DOCKERFILE_TEMPLATE.format( DOCKERFILE_TEMPLATE.format(
timeout=timeout, timeout=timeout,
**package_versions, **package_versions,
go2rtc=GO2RTC_VERSION, go2rtc_sha=_GORTC_DOCKER_SHA,
), ),
config.root / "Dockerfile", config.root / "Dockerfile",
), ),

View File

@@ -29,7 +29,6 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \
tqdm==4.67.1 \ tqdm==4.67.1 \
ruff==0.13.0 \ ruff==0.13.0 \
PyTurboJPEG==1.8.0 \ PyTurboJPEG==1.8.0 \
go2rtc-client==0.2.1 \
ha-ffmpeg==3.2.2 \ ha-ffmpeg==3.2.2 \
hassil==3.4.0 \ hassil==3.4.0 \
home-assistant-intents==2025.11.7 \ home-assistant-intents==2025.11.7 \

View File

@@ -174,6 +174,7 @@ def gen_data_entry_schema(
flow_title: int, flow_title: int,
require_step_title: bool, require_step_title: bool,
mandatory_description: str | None = None, mandatory_description: str | None = None,
subentry_flow: bool = False,
) -> vol.All: ) -> vol.All:
"""Generate a data entry schema.""" """Generate a data entry schema."""
step_title_class = vol.Required if require_step_title else vol.Optional step_title_class = vol.Required if require_step_title else vol.Optional
@@ -206,8 +207,12 @@ def gen_data_entry_schema(
vol.Optional("abort"): {str: translation_value_validator}, vol.Optional("abort"): {str: translation_value_validator},
vol.Optional("progress"): {str: translation_value_validator}, vol.Optional("progress"): {str: translation_value_validator},
vol.Optional("create_entry"): {str: translation_value_validator}, vol.Optional("create_entry"): {str: translation_value_validator},
vol.Optional("initiate_flow"): {str: translation_value_validator}, }
vol.Optional("entry_type"): translation_value_validator, if subentry_flow:
schema[vol.Required("entry_type")] = translation_value_validator
schema[vol.Required("initiate_flow")] = {
vol.Required("user"): translation_value_validator,
str: translation_value_validator,
} }
if flow_title == REQUIRED: if flow_title == REQUIRED:
schema[vol.Required("title")] = translation_value_validator schema[vol.Required("title")] = translation_value_validator
@@ -314,6 +319,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
integration=integration, integration=integration,
flow_title=REMOVED, flow_title=REMOVED,
require_step_title=False, require_step_title=False,
subentry_flow=True,
), ),
slug_validator=vol.Any("_", cv.slug), slug_validator=vol.Any("_", cv.slug),
), ),

View File

@@ -6,9 +6,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.config_entry_oauth2_flow import ( from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError, ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
) )
from . import api from . import api
@@ -26,17 +28,13 @@ type New_NameConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth]
async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: New_NameConfigEntry) -> bool:
"""Set up NEW_NAME from a config entry.""" """Set up NEW_NAME from a config entry."""
try: try:
implementation = ( implementation = await async_get_config_entry_implementation(hass, entry)
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
except ImplementationUnavailableError as err: except ImplementationUnavailableError as err:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
"OAuth2 implementation temporarily unavailable, will retry" "OAuth2 implementation temporarily unavailable, will retry"
) from err ) from err
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) session = OAuth2Session(hass, entry, implementation)
# If using a requests-based API lib # If using a requests-based API lib
entry.runtime_data = api.ConfigEntryAuth(hass, session) entry.runtime_data = api.ConfigEntryAuth(hass, session)

View File

@@ -1,11 +1,11 @@
"""Test Ambient Weather Network sensors.""" """Test Ambient Weather Network sensors."""
from datetime import datetime, timedelta from datetime import timedelta
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from aioambient import OpenAPI from aioambient import OpenAPI
from aioambient.errors import RequestError from aioambient.errors import RequestError
from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@@ -18,7 +18,7 @@ from .conftest import setup_platform
from tests.common import async_fire_time_changed, snapshot_platform from tests.common import async_fire_time_changed, snapshot_platform
@freeze_time("2023-11-9") @pytest.mark.freeze_time("2023-11-9")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"config_entry", "config_entry",
["AA:AA:AA:AA:AA:AA", "CC:CC:CC:CC:CC:CC", "DD:DD:DD:DD:DD:DD"], ["AA:AA:AA:AA:AA:AA", "CC:CC:CC:CC:CC:CC", "DD:DD:DD:DD:DD:DD"],
@@ -54,17 +54,17 @@ async def test_sensors_with_no_data(
@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) @pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True)
@pytest.mark.freeze_time("2023-11-8")
async def test_sensors_disappearing( async def test_sensors_disappearing(
hass: HomeAssistant, hass: HomeAssistant,
open_api: OpenAPI, open_api: OpenAPI,
aioambient: AsyncMock, aioambient: AsyncMock,
config_entry: ConfigEntry, config_entry: ConfigEntry,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None: ) -> None:
"""Test that we log errors properly.""" """Test that we log errors properly."""
initial_datetime = datetime(year=2023, month=11, day=8)
with freeze_time(initial_datetime) as frozen_datetime:
# Normal state, sensor is available. # Normal state, sensor is available.
await setup_platform(True, hass, config_entry) await setup_platform(True, hass, config_entry)
sensor = hass.states.get("sensor.station_a_relative_pressure") sensor = hass.states.get("sensor.station_a_relative_pressure")
@@ -74,10 +74,8 @@ async def test_sensors_disappearing(
# Sensor becomes unavailable if the network is unavailable. Log message # Sensor becomes unavailable if the network is unavailable. Log message
# should only show up once. # should only show up once.
for _ in range(5): for _ in range(5):
with patch.object( with patch.object(open_api, "get_device_details", side_effect=RequestError()):
open_api, "get_device_details", side_effect=RequestError() freezer.tick(timedelta(minutes=10))
):
frozen_datetime.tick(timedelta(minutes=10))
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
@@ -89,7 +87,7 @@ async def test_sensors_disappearing(
# Network comes back. Sensor should start reporting again. Log message # Network comes back. Sensor should start reporting again. Log message
# should only show up once. # should only show up once.
for _ in range(5): for _ in range(5):
frozen_datetime.tick(timedelta(minutes=10)) freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
sensor = hass.states.get("sensor.station_a_relative_pressure") sensor = hass.states.get("sensor.station_a_relative_pressure")

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