Compare commits

...

74 Commits

Author SHA1 Message Date
Petar Petrov adeae40ce1 Strip unknown labels instead of erroring 2026-06-11 11:00:57 +03:00
Petar Petrov a6d3fb1808 Reject unknown label ids in registry websocket APIs 2026-06-10 14:01:04 +03:00
Manu 158a8b8c69 Add OptionsFlow to SMTP integration (#173386) 2026-06-10 12:00:28 +02:00
Martin Hjelmare 102cb4b69e Add pylint enforce dt.now checker (#173005) 2026-06-10 11:48:49 +02:00
Duco Sebel f3a3f4cde4 Remove positional message strings when translation_key is set in blink (#173390) 2026-06-10 11:47:00 +02:00
Jonathan Laliberte 19880b4214 Fix roomba charging state and add charging binary sensor (#173304) 2026-06-10 11:26:25 +02:00
Michael Davie 27599cbfe3 Bump env-canada to 0.15.0 (#173408)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:17:28 +02:00
Andreas Schneider 54cb4a7946 Bump babel to version 2.18.0 (#173424) 2026-06-10 11:01:43 +02:00
dependabot[bot] 4a5ee9e4ee Bump astral-sh/setup-uv from 8.1.0 to 8.2.0 (#173418)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-10 10:43:13 +02:00
Duco Sebel 8111667c1f Remove positional message strings when translation_key is set in teslemetry (#173391) 2026-06-10 10:26:47 +02:00
Crocmagnon 03eb139f6e ovhcloud_ai_endpoints: fix typo (#173410) 2026-06-10 08:15:29 +02:00
Stef Coene 426213dd29 velbus: allow device and sub-device removal (#168283)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-09 23:17:31 +02:00
Duco Sebel 8e659530da Remove 'home-assistant-exception-message-with-translation' pylint exception from mqtt (#173396) 2026-06-09 23:14:52 +02:00
Duco Sebel 76c28444e8 Remove 'home-assistant-exception-message-with-translation' pylint exception from bsblan (#173394) 2026-06-09 23:01:14 +02:00
Duco Sebel f4a9514d35 Remove 'home-assistant-exception-message-with-translation' pylint exception from nfandroidtv (#173398) 2026-06-09 23:00:48 +02:00
dependabot[bot] 6f5f59608c Bump github/codeql-action from 4.36.0 to 4.36.1 (#173333)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 22:41:59 +02:00
Will Pike fc8040784e Bump python-ecobee-api to 0.4.1 (#172601) 2026-06-09 22:18:28 +02:00
dependabot[bot] 8e77ef3e1b Bump actions/checkout from 6.0.2 to 6.0.3 (#173331)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 22:16:39 +02:00
dependabot[bot] 95257e36ef Bump github/gh-aw-actions from 0.77.3 to 0.78.1 (#173332)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 22:16:16 +02:00
Michael Hansen 89a600dc34 Only allow specific protocols with ffmpeg in Wyoming satellite announce (#173381)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 20:12:07 +02:00
Åke Strandberg 2b530a1dfa Add exception translations for aqvify (#173361) 2026-06-09 18:29:30 +02:00
Duco Sebel 9a01a0d758 Remove positional message strings when translation_key is set in homewizard (#173377) 2026-06-09 18:05:45 +02:00
Manu 145538c563 Improve strings in SMTP integration (#173379) 2026-06-09 18:02:50 +02:00
Nikolai Rahimi 6f09fc074d Bump mitsubishi-comfort to 0.3.1 (#173362) 2026-06-09 17:41:32 +02:00
Joost Lekkerkerker 372c6697a0 Set Zinvolt max output to 2kW if unlocked (#173367) 2026-06-09 16:41:21 +02:00
Joost Lekkerkerker bbd82ce511 Handle unavailable Zinvolt devices better (#173359) 2026-06-09 15:24:14 +02:00
Jan Bouwhuis bcb0908d0a Fix reload fails when MQTT entry is not set up (#173335)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-09 15:22:00 +02:00
Joakim Plate da5f497091 Ensure we provide strings to vol.In for philips js (#173313) 2026-06-09 14:42:38 +02:00
Åke Strandberg c28e215ae6 Add reauth flow to aqvify (#173287)
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2026-06-09 12:50:19 +02:00
Manu ee2fb6e150 Add config flow to SMTP integration (#172019) 2026-06-09 12:38:22 +02:00
Charles Vestal 9bdb2e21fe Fix HomeKit crash on integer device trigger subtypes (#173334)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:15:46 +02:00
Triggs d2277ddbd7 Bump codecov/codecov-action from v6.0.1 to v7.0.0 (#173232)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-06-09 11:50:39 +02:00
Paul Bottein cbce4b8e76 Add reauthentication flow to Yoto (#173243) 2026-06-09 11:25:08 +02:00
renovate[bot] bb5d3fe67f Update uv to 0.11.18 (#173327) 2026-06-09 10:39:18 +02:00
Simone Chemelli 33753460ab Improve and complete exception handling for Alexa Devices (#173053) 2026-06-09 09:59:12 +02:00
Åke Strandberg d815487f7f Add diagnostics platform to aqvify (#173283) 2026-06-09 09:06:07 +02:00
Mick Vleeshouwer 5d3f7001ab Clean up redundant URL parsing in the Overkiz (#173273) 2026-06-09 08:46:02 +02:00
AlCalzone 2287c92f88 Bump zwave-js-server-python to 0.72.0 (#173309)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:44:07 +02:00
Marcello b1610163f1 Set PARALLEL_UPDATES for Fluss platforms (#173286) 2026-06-09 08:35:09 +02:00
Raphael Hehl 1cfae60c03 Bump uiprotect to 12.0.0 (#173315) 2026-06-09 06:23:58 +02:00
Åke Strandberg 49d1e0ea5b Bump pyaqvify to 0.0.9 (#173312) 2026-06-09 06:20:41 +02:00
Hai-Nam Nguyen 05f4e28c86 Bump hyponcloud to 1.0.0 (#173310) 2026-06-09 06:19:10 +02:00
renovate[bot] 2b5fc802c4 Update rf-protocols to 4.1.0 (#173328)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-09 06:14:11 +02:00
tronikos 9b5038d741 Bump opower to 0.18.4 (#173323) 2026-06-09 06:08:26 +02:00
G Johansson 7c33b953d3 Remove not needed guards for integration migrations from future versions (#173301) 2026-06-08 15:57:25 -04:00
Martin Hjelmare 5ffd772868 Fix homeassistant hardware unique id migration (#173258) 2026-06-08 21:39:19 +02:00
Marcello 392c7f97c8 Add individual code owner for Fluss (#173276) 2026-06-08 21:38:49 +02:00
Allen Porter 0672c940a4 Use roboorck device capabilities to determine which entities are supported (#173282) 2026-06-08 20:36:54 +02:00
Michael Hansen 03b0b4ad8b Allow inline number ranges for sentence triggers (#173111)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-08 14:22:17 -04:00
cnico 9b84fc9dba Update dio-chacon-wifi-api to 1.3.0 (#173240) 2026-06-08 20:17:00 +02:00
Michael Hansen e38e6ecec8 Mitigate TTS ResultStream leak in pipeline (#173290) 2026-06-08 12:13:53 -04:00
Simone Chemelli 37e4f1ab32 Bump renault-api to 0.5.12 (#173289) 2026-06-08 17:52:22 +02:00
G Johansson 18d17a5346 Config entry migration error on downgrading (#173184) 2026-06-08 17:45:24 +02:00
Crocmagnon d10ede2264 data grand lyon: list stops and lines in config flow (#173117) 2026-06-08 17:25:01 +02:00
Jeef 05088bf991 Add initial quality scale for Weatherflow local (#166022)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com>
2026-06-08 18:11:03 +03:00
epenet fbf14c63c0 Fix incorrect use of Platform in atag (#173025) 2026-06-08 16:55:50 +02:00
epenet 397c28b9b6 Use DOMAIN constant in test (async_setup_component h-n) (#173015) 2026-06-08 16:28:17 +02:00
Glenn Waters 676a8c39eb Environment Canada integration: add get_alerts action (#172393) 2026-06-08 16:16:42 +02:00
epenet d4accebb3b Use DOMAIN constant in test (async_setup_component a-g) (#173013) 2026-06-08 15:58:54 +02:00
Åke Strandberg b8bdd2c47c Add new Aqvify integration (#172936) 2026-06-08 15:45:03 +02:00
Evan Severson 828ec639dd Strip trailing slash from Jellyfin server URL (#173049) 2026-06-08 15:43:58 +02:00
Martin Claesson df4fbc91f9 Add Kiosker service platform (#171094) 2026-06-08 15:42:34 +02:00
G Johansson 1a4a95df83 Use query_dns from aiodns in dnsip (#173257) 2026-06-08 15:11:41 +02:00
Crocmagnon e4b5818b56 ovhcloud_ai_endpoints: add reconfigure flow (#172583) 2026-06-08 14:00:09 +02:00
starkillerOG c9ad482293 Adjust ONVIF event fallbacks for battery cameras (#173214)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-08 13:51:10 +02:00
peteS-UK 78cc155e56 Update PARALLEL_UPDATES to 0 for Squeezebox platforms (#172906) 2026-06-08 13:45:29 +02:00
Hai-Nam Nguyen 145639d048 Add load, grid, and battery sensors to Hypontech (#173150)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:40:37 +02:00
bkobus-bbx 3e3e9af30d Add state_class to blebox sensors (#173253)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-08 12:04:04 +02:00
Diogo Gomes c27e43c570 Moves V2C InstallationVoltage from Sensor to Number (#169771)
Co-authored-by: Samuel Cabrero <scabrero@suse.com>
Co-authored-by: Samuel Cabrero <samuel@orica.es>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Samuel Cabrero <samuel@orica.es>
2026-06-08 11:47:12 +02:00
cb2206 4f4aeff2b4 Lutron caseta prev brightness (#164080)
Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:41:06 +02:00
Diogo Gomes 850cc27824 Bump pytrydan to v1.0.1 (#173047) 2026-06-08 11:24:47 +02:00
Erik Montnemery e19c063ef1 Improve tests of humanized error messages (#173256) 2026-06-08 11:08:05 +02:00
Colin 707742f720 Bump python-openevse-http to 1.0.1 (#172982) 2026-06-08 10:46:27 +02:00
Ronald van der Meer f58e0e5234 Fix Duco box device removal on partial node refreshes (#173186) 2026-06-08 09:48:27 +02:00
430 changed files with 9676 additions and 1865 deletions
+6 -6
View File
@@ -38,7 +38,7 @@ jobs:
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -245,7 +245,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -292,7 +292,7 @@ jobs:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -471,7 +471,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -518,7 +518,7 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -40,7 +40,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Python
+11 -11
View File
@@ -31,12 +31,12 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
# - github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
@@ -90,7 +90,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -133,7 +133,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
sparse-checkout: |
@@ -352,7 +352,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -372,7 +372,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Create gh-aw temp directory
@@ -961,7 +961,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1100,7 +1100,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1127,7 +1127,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1325,7 +1325,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1383,7 +1383,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+24 -24
View File
@@ -98,7 +98,7 @@ jobs:
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Generate partial Python venv restore key
@@ -264,7 +264,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Register problem matchers
@@ -291,7 +291,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Run zizmor
@@ -318,7 +318,7 @@ jobs:
- script/hassfest/docker/Dockerfile
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Register hadolint problem matcher
@@ -341,7 +341,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
@@ -404,7 +404,7 @@ jobs:
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
- name: Set up uv
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ steps.read-uv-version.outputs.version }}
- name: Create Python virtual environment
@@ -469,7 +469,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -512,7 +512,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Python
@@ -548,7 +548,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Python
@@ -576,7 +576,7 @@ jobs:
&& github.event_name == 'pull_request'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Dependency review
@@ -603,7 +603,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
@@ -654,7 +654,7 @@ jobs:
|| github.event.inputs.pylint-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Python
@@ -707,7 +707,7 @@ jobs:
&& (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Python
@@ -758,7 +758,7 @@ jobs:
|| github.event.inputs.mypy-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Set up Python
@@ -825,7 +825,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -889,7 +889,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1030,7 +1030,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1179,7 +1179,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1317,7 +1317,7 @@ jobs:
if: needs.info.outputs.skip_coverage != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Download all coverage artifacts
@@ -1326,7 +1326,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
fail_ci_if_error: true
flags: full-suite
@@ -1355,7 +1355,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1476,7 +1476,7 @@ jobs:
- pytest-partial
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Download all coverage artifacts
@@ -1485,7 +1485,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1513,7 +1513,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
report_type: test_results
fail_ci_if_error: true
+3 -3
View File
@@ -23,16 +23,16 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
category: "/language:python"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+3 -3
View File
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -116,7 +116,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -167,7 +167,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+1
View File
@@ -96,6 +96,7 @@ homeassistant.components.aprs.*
homeassistant.components.apsystems.*
homeassistant.components.aqualogic.*
homeassistant.components.aquostv.*
homeassistant.components.aqvify.*
homeassistant.components.aranet.*
homeassistant.components.arcam_fmj.*
homeassistant.components.arris_tg2492lg.*
Generated
+4 -2
View File
@@ -162,6 +162,8 @@ CLAUDE.md @home-assistant/core
/tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH
/homeassistant/components/aquacell/ @Jordi1990
/tests/components/aquacell/ @Jordi1990
/homeassistant/components/aqvify/ @astrandb
/tests/components/aqvify/ @astrandb
/homeassistant/components/aranet/ @aschmitz @thecode @anrijs
/tests/components/aranet/ @aschmitz @thecode @anrijs
/homeassistant/components/arcam_fmj/ @elupus
@@ -574,8 +576,8 @@ CLAUDE.md @home-assistant/core
/tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/fluss/ @fluss @Marcello17
/tests/components/fluss/ @fluss @Marcello17
/homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
@@ -116,10 +116,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Migrate old config entry."""
# This means the user has downgraded from a future version
if entry.version > 2:
return False
# 1.1 Migrate config_entry to add advanced ssl settings
if entry.version == 1 and entry.minor_version == 1:
new_minor_version = 2
@@ -65,10 +65,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version < 3:
if CONF_SITE in entry.data:
# Site in data (wrong place), just move to login data
@@ -6,7 +6,7 @@ from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .entity import AmazonServiceEntity
# Coordinator is used to centralize the data updates
@@ -49,4 +49,5 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
async def async_press(self) -> None:
"""Handle button press action."""
await self.coordinator.api.call_routine(self._routine)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_routine(self._routine)
@@ -1,5 +1,7 @@
"""Support for Alexa Devices."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import timedelta
from aioamazondevices.api import AmazonEchoApi
@@ -19,7 +21,11 @@ from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -29,6 +35,65 @@ from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
SCAN_INTERVAL = 300
@asynccontextmanager
async def alexa_api_call(
coordinator: DataUpdateCoordinator | None = None,
) -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as HomeAssistantError."""
try:
yield
except CannotAuthenticate as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except CannotConnect as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
@asynccontextmanager
async def alexa_config_entry_errors() -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as ConfigEntry errors."""
try:
yield
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError, KeyError, StopIteration) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
@@ -113,6 +178,12 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except ValueError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
else:
current_devices = set(data.keys())
if stale_devices := self.previous_devices - current_devices:
@@ -169,26 +240,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_history_state(self) -> None:
"""Sync history state."""
try:
async with alexa_config_entry_errors():
self._vocal_records = await self.api.sync_history_state()
except CannotAuthenticate as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(e)},
) from e
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(e)},
) from e
except BaseException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(e)},
) from e
async def history_state_event_handler(
self, vocal_records: dict[str, AmazonVocalRecord]
@@ -204,26 +257,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_media_state(self) -> None:
"""Sync media state."""
try:
async with alexa_config_entry_errors():
await self.api.sync_media_state()
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
@@ -22,9 +22,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -216,16 +215,15 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
provider = media_type.value if isinstance(media_type, MediaType) else media_type
await self.async_call_alexa_music(media_id, provider)
@alexa_api_call
async def async_call_alexa_music(
self, search_phrase: str, provider_id: str
) -> None:
"""Call alexa music."""
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
@alexa_api_call
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
@@ -233,7 +231,8 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self.device.serial_number,
volume,
)
await self.coordinator.api.set_device_volume(self.device, volume)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.set_device_volume(self.device, volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level (0.0 to 1.0)."""
@@ -263,12 +262,12 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
await self.async_set_volume_level(target_volume / 100)
self._prev_volume = None
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
await self.coordinator.api.send_media_command(self.device, command)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.send_media_command(self.device, command)
async def async_media_stop(self) -> None:
"""Send stop command."""
@@ -12,9 +12,8 @@ from homeassistant.components.notify import NotifyEntity, NotifyEntityDescriptio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .coordinator import AmazonConfigEntry, alexa_api_call
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -80,10 +79,11 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription
@alexa_api_call
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:
"""Send a message."""
await self.entity_description.method(self.coordinator.api, self.device, message)
async with alexa_api_call(self.coordinator):
await self.entity_description.method(
self.coordinator.api, self.device, message
)
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN, INFO_SKILLS_MAPPING
from .coordinator import AmazonConfigEntry
from .coordinator import AmazonConfigEntry, alexa_api_call
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
@@ -85,13 +85,15 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_sound_value",
translation_placeholders={"sound": value},
)
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
async with alexa_api_call():
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_TEXT_COMMAND:
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
async with alexa_api_call():
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_INFO_SKILL:
info_skill = INFO_SKILLS_MAPPING.get(value)
if info_skill not in ALEXA_INFO_SKILLS:
@@ -100,9 +102,10 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_info_skill_value",
translation_placeholders={"info_skill": value},
)
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
async with alexa_api_call():
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
async def async_send_sound_notification(call: ServiceCall) -> None:
@@ -14,13 +14,9 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .coordinator import AmazonConfigEntry, alexa_api_call
from .entity import AmazonEntity
from .utils import (
alexa_api_call,
async_remove_dnd_from_virtual_group,
async_update_unique_id,
)
from .utils import async_remove_dnd_from_virtual_group, async_update_unique_id
PARALLEL_UPDATES = 1
@@ -90,7 +86,6 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
entity_description: AmazonSwitchEntityDescription
@alexa_api_call
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)
@@ -98,7 +93,8 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
if TYPE_CHECKING:
assert method is not None
await method(self.device, state)
async with alexa_api_call(self.coordinator):
await method(self.device, state)
self.coordinator.data[self.device.serial_number].sensors[
self.entity_description.key
].value = state
@@ -1,54 +1,19 @@
"""Utils for Alexa Devices."""
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.const.schedules import (
NOTIFICATION_ALARM,
NOTIFICATION_REMINDER,
NOTIFICATION_TIMER,
)
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonDevicesCoordinator
from .entity import AmazonEntity
def alexa_api_call[_T: AmazonEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
"""Catch Alexa API call exceptions."""
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
try:
await func(self, *args, **kwargs)
except CannotConnect as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
return cmd_wrapper
async def async_update_unique_id(
@@ -75,10 +75,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> b
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version == 1:
new_data = {**entry.data}
if CONF_DEVICES in new_data:
@@ -178,10 +178,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
"""Migrate entry."""
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 2 and entry.minor_version == 1:
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
@@ -0,0 +1,28 @@
"""The Aqvify integration."""
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AqvifyConfigEntry, AqvifyCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
"""Set up Aqvify from a config entry."""
coordinator = AqvifyCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
"""Unload Aqvify config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,98 @@
"""Config flow for the Aqvify integration."""
from collections.abc import Mapping
import logging
from typing import Any
from aiohttp import ClientResponseError
from pyaqvify import AqvifyAPI, AqvifyAuthException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
)
class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aqvify."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
hub = AqvifyAPI(
user_input[CONF_API_KEY],
websession=async_get_clientsession(self.hass),
)
try:
account_data = await hub.async_get_account_id()
except AqvifyAuthException:
errors["base"] = "invalid_auth"
except ClientResponseError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(account_data.account_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Aqvify", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"aqvify_url": "https://app.aqvify.com/User",
},
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication confirmation."""
errors = {}
if user_input is not None:
api_client = AqvifyAPI(
user_input[CONF_API_KEY],
websession=async_get_clientsession(self.hass),
)
try:
account_data = await api_client.async_get_account_id()
except AqvifyAuthException:
errors["base"] = "invalid_auth"
except ClientResponseError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(account_data.account_id)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
+3
View File
@@ -0,0 +1,3 @@
"""Constants for the Aqvify integration."""
DOMAIN = "aqvify"
@@ -0,0 +1,137 @@
"""Coordinator for Aqvify integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from aiohttp import ClientResponseError
from pyaqvify import AqvifyAPI, AqvifyAuthException, AqvifyDeviceData, AqvifyDevices
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
type AqvifyConfigEntry = ConfigEntry[AqvifyCoordinator]
@dataclass
class AqvifyCoordinatorData:
"""Data class for storing coordinator data."""
devices: AqvifyDevices
device_data: dict[str, AqvifyDeviceData]
class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
"""Data update coordinator for Aqvify devices."""
config_entry: AqvifyConfigEntry
def __init__(self, hass: HomeAssistant, entry: AqvifyConfigEntry) -> None:
"""Initialize the Aqvify data update coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
self.api_client = AqvifyAPI(
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.api_client.async_get_account_id()
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
async def _async_update_data(self) -> AqvifyCoordinatorData:
"""Fetch device state."""
try:
devices = await self.api_client.async_get_devices()
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
device_data = {}
for device in devices.devices.values():
try:
device_key = str(device.device_key)
device_data[
device_key
] = await self.api_client.async_get_device_latest_data(device_key)
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
return AqvifyCoordinatorData(
devices=devices,
device_data=device_data,
)
@@ -0,0 +1,30 @@
"""Diagnostics platform for Aqvify integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from .coordinator import AqvifyConfigEntry
TO_REDACT = [CONF_API_KEY]
TO_REDACT_AQVIFY = ["name"]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AqvifyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
device_list_raw_data = entry.runtime_data.data.devices.raw
device_data_raw_data = {
key: device.raw_data
for key, device in entry.runtime_data.data.device_data.items()
}
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"devices": async_redact_data(device_list_raw_data, TO_REDACT_AQVIFY),
"device_data": device_data_raw_data,
}
+35
View File
@@ -0,0 +1,35 @@
"""Defines a base Aqvify entity."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AqvifyCoordinator
class AqvifyBaseEntity(CoordinatorEntity[AqvifyCoordinator]):
"""Defines a base Aqvify entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AqvifyCoordinator,
description: EntityDescription,
device_key: str,
) -> None:
"""Initialize the Aqvify entity."""
super().__init__(coordinator)
account_id = self.coordinator.config_entry.unique_id
self.device_key = device_key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{account_id}_{device_key}")},
name=coordinator.data.devices.devices[device_key].name,
manufacturer="Aqvify",
configuration_url="https://app.aqvify.com",
serial_number=device_key,
)
self._attr_unique_id = f"{account_id}_{device_key}_{description.key}"
self.entity_description = description
@@ -0,0 +1,12 @@
{
"entity": {
"sensor": {
"meter_value": {
"default": "mdi:waves-arrow-up"
},
"water_level": {
"default": "mdi:waves"
}
}
}
}
@@ -0,0 +1,12 @@
{
"domain": "aqvify",
"name": "Aqvify",
"codeowners": ["@astrandb"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aqvify",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "bronze",
"requirements": ["pyaqvify==0.0.9"]
}
@@ -0,0 +1,69 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No actions in this integration.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
+79
View File
@@ -0,0 +1,79 @@
"""Sensor platform for Aqvify integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from pyaqvify import AqvifyDeviceData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AqvifyConfigEntry
from .entity import AqvifyBaseEntity
# Coordinator is used to centralize the data updates.
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AqvifySensorEntityDescription(SensorEntityDescription):
"""Description of an Aqvify sensor entity."""
value_fn: Callable[[AqvifyDeviceData], float | int | None]
ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
AqvifySensorEntityDescription(
key="meter_value",
translation_key="meter_value",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=2,
value_fn=lambda value: value.meter_value,
),
AqvifySensorEntityDescription(
key="water_level",
translation_key="water_level",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=2,
value_fn=lambda value: value.water_level,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AqvifyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aqvify sensor entities from a config entry."""
async_add_entities(
AqvifySensor(entry.runtime_data, description, device_key)
for description in ENTITIES
for device_key in entry.runtime_data.data.devices.devices
)
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
"""Representation of an Aqvify sensor entity."""
entity_description: AqvifySensorEntityDescription
@property
def native_value(self) -> StateType | datetime | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(
self.coordinator.data.device_data[self.device_key]
)
@@ -0,0 +1,55 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The entered API key corresponds to a different account."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::aqvify::config::step::user::data_description::api_key%]"
},
"description": "Reauthentication required. Please enter your updated API key."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your Aqvify API key"
},
"description": "Navigate to your [Aqvify account]({aqvify_url}), copy your API key, and paste it below."
}
}
},
"entity": {
"sensor": {
"meter_value": {
"name": "Meter value"
},
"water_level": {
"name": "Water level"
}
}
},
"exceptions": {
"api_error": {
"message": "An error occurred while communicating with the Aqvify API for {entry}"
},
"api_timeout": {
"message": "Timeout occurred while communicating with the Aqvify API for {entry}"
},
"invalid_api_key": {
"message": "Invalid API key. Please verify your API key and try to reauthenticate."
}
}
}
@@ -1816,6 +1816,11 @@ class PipelineInput:
await self.run.text_to_speech(tts_input)
except PipelineError as err:
if self.run.tts_stream:
# Clean up TTS stream
self.run.tts_stream.delete()
self.run.tts_stream = None
self.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
@@ -1885,15 +1890,17 @@ class PipelineInput:
):
prepare_tasks.append(self.run.prepare_recognize_intent(self.session))
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
# Do TTS prepare separately so we don't create a ResultStream if the
# pipeline is invalid.
if (
start_stage_index
<= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS)
<= end_stage_index
):
prepare_tasks.append(self.run.prepare_text_to_speech())
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
await self.run.prepare_text_to_speech()
class PipelinePreferred(CollectionError):
@@ -3,6 +3,7 @@
from dataclasses import asdict
import logging
from pathlib import Path
import re
from typing import Any
from hassil.parse_expression import parse_sentence
@@ -204,6 +205,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
# Exclude {list_references} which may contain punctuation characters.
sentence = _remove_list_references(sentence)
if (
PUNCTUATION_START.search(sentence)
or PUNCTUATION_END.search(sentence)
@@ -215,6 +218,11 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def _remove_list_references(sentence: str) -> str:
"""Remove {list_references} from a sentence for linting."""
return re.sub(r"(?<!\\)\{[^{}]*\}", "", sentence)
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
@@ -222,7 +230,6 @@ def is_valid_sentence(value: list[str]) -> list[str]:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
@@ -18,6 +18,9 @@
}
},
"sensor": {
"open_status": {
"default": "mdi:window-open"
},
"power_consumption": {
"default": "mdi:lightning-bolt"
}
+15 -1
View File
@@ -50,21 +50,25 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
key="pm1",
device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="pm2_5",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="pm10",
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="powerConsumption",
@@ -76,62 +80,72 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="wind",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="illuminance",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="forwardActiveEnergy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="reverseActiveEnergy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="reactivePower",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="activePower",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="apparentPower",
device_class=SensorDeviceClass.APPARENT_POWER,
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="frequency",
device_class=SensorDeviceClass.FREQUENCY,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="openStatus",
translation_key="open_status",
device_class=SensorDeviceClass.ENUM,
icon="mdi:window-open",
options=list(OPEN_STATUS.values()),
value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None,
),
-4
View File
@@ -169,9 +169,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try:
await self._camera.save_recent_clips(output_dir=file_path)
except OSError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
@@ -191,9 +189,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try:
await self._camera.video_to_file(filename)
except OSError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
+1 -1
View File
@@ -54,7 +54,7 @@
},
"exceptions": {
"cant_write": {
"message": "Can't write to file."
"message": "Can't write to file, check logs for details."
},
"failed_arm": {
"message": "Blink failed to arm camera."
@@ -230,10 +230,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
entry.minor_version,
)
if entry.version > 1:
# Downgraded from a future version; cannot migrate.
return False
# 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
@@ -183,7 +183,6 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
try:
await self.coordinator.client.thermostat(**data, circuit=self._circuit)
except BSBLANError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_data_error",
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
"iot_class": "cloud_push",
"loggers": ["dio_chacon_api"],
"requirements": ["dio-chacon-wifi-api==1.2.2"]
"requirements": ["dio-chacon-wifi-api==1.3.0"]
}
@@ -87,10 +87,6 @@ async def async_migrate_entry(
) -> bool:
"""Migrate old entry."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1 and config_entry.minor_version == 1:
device_registry = dr.async_get(hass)
@@ -6,7 +6,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import area_registry as ar, label_registry as lr
@callback
@@ -69,8 +69,9 @@ def websocket_create_area(
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# Convert labels to a set
data["labels"] = set(data["labels"])
# Strip labels which are not in the label registry
labels = set(data["labels"])
data["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
try:
entry = registry.async_create(**data)
@@ -139,8 +140,11 @@ def websocket_update_area(
data["aliases"] = {s_strip for s in data["aliases"] if (s_strip := s.strip())}
if "labels" in data:
# Convert labels to a set
data["labels"] = set(data["labels"])
# Strip labels which are not in the label registry. This also cleans up
# any stale labels already stored on the area (e.g. left behind by a
# deleted label) the next time it is saved.
labels = set(data["labels"])
data["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
try:
entry = registry.async_update(**data)
@@ -9,7 +9,7 @@ from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, label_registry as lr
from homeassistant.helpers.device_registry import DeviceEntry, DeviceEntryDisabler
@@ -84,8 +84,11 @@ def websocket_update_device(
msg["disabled_by"] = DeviceEntryDisabler(msg["disabled_by"])
if "labels" in msg:
# Convert labels to a set
msg["labels"] = set(msg["labels"])
# Strip labels which are not in the label registry. This also cleans up
# any stale labels already stored on the device (e.g. left behind by a
# deleted label) the next time it is saved.
labels = set(msg["labels"])
msg["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
entry = cast(DeviceEntry, registry.async_update_device(**msg))
@@ -13,6 +13,7 @@ from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
label_registry as lr,
)
from homeassistant.helpers.json import json_dumps
@@ -234,8 +235,11 @@ def websocket_update_entity(
aliases.append(alias)
if "labels" in msg:
# Convert labels to a set
changes["labels"] = set(msg["labels"])
# Strip labels which are not in the label registry. This also cleans up
# any stale labels already stored on the entity (e.g. left behind by a
# deleted label) the next time it is saved.
labels = set(msg["labels"])
changes["labels"] = labels - lr.async_get_missing_label_ids(hass, labels)
if "disabled_by" in msg and msg["disabled_by"] is None:
# Don't allow enabling an entity of a disabled device
@@ -1,6 +1,7 @@
"""Offer sentence based automation rules."""
from collections.abc import Awaitable, Callable
import re
from typing import Any
from hassil.parse_expression import parse_sentence
@@ -33,6 +34,8 @@ TRIGGER_CALLBACK_TYPE = Callable[
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
# Exclude {list_references} which may contain punctuation characters.
sentence = _remove_list_references(sentence)
if (
PUNCTUATION_START.search(sentence)
or PUNCTUATION_END.search(sentence)
@@ -44,6 +47,11 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def _remove_list_references(sentence: str) -> str:
"""Remove {list_references} from a sentence for linting."""
return re.sub(r"(?<!\\)\{[^{}]*\}", "", sentence)
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
@@ -51,7 +59,6 @@ def is_valid_sentence(value: list[str]) -> list[str]:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
@@ -5,7 +5,7 @@ import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError
from data_grand_lyon_ha import DataGrandLyonClient
from data_grand_lyon_ha import DataGrandLyonClient, TclStop, find_tcl_stop_by_id
import voluptuous as vol
from homeassistant.config_entries import (
@@ -18,6 +18,12 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_LINE,
@@ -43,13 +49,6 @@ STEP_RECONFIGURE_SCHEMA = vol.Schema(
}
)
STEP_STOP_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LINE): str,
vol.Required(CONF_STOP_ID): vol.Coerce(int),
}
)
STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_STATION_ID): vol.Coerce(int),
@@ -179,33 +178,126 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
class StopSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Data Grand Lyon stop."""
def __init__(self) -> None:
"""Initialize the flow."""
self._stops: list[TclStop] = []
self._selected_stop: TclStop | None = None
self._selected_stop_id: int | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the user step to add a new stop."""
entry = self._get_entry()
"""Pick a stop from the list fetched from the API, or enter one manually."""
if not self._stops:
if error := await self._async_load_stops():
return self.async_abort(reason=error)
errors: dict[str, str] = {}
if user_input is not None:
line = user_input[CONF_LINE]
stop_id = user_input[CONF_STOP_ID]
unique_id = f"{line}_{stop_id}"
try:
stop_id = int(user_input[CONF_STOP_ID])
except ValueError:
errors[CONF_STOP_ID] = "invalid_stop_id"
else:
self._selected_stop_id = stop_id
self._selected_stop = find_tcl_stop_by_id(self._stops, stop_id)
return await self.async_step_pick_line()
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
name = f"{line} - Stop {stop_id}"
return self.async_create_entry(
title=name,
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
unique_id=unique_id,
options = [
SelectOptionDict(value=str(stop.id), label=_stop_label(stop))
for stop in sorted(
self._stops, key=lambda s: (s.nom, s.commune or "", s.id or 0)
)
]
schema = vol.Schema(
{
vol.Required(CONF_STOP_ID): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
sort=False,
custom_value=True,
)
)
}
)
return self.async_show_form(
step_id="user",
data_schema=STEP_STOP_DATA_SCHEMA,
data_schema=schema,
errors=errors,
)
async def async_step_pick_line(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Pick a line from the selected stop's desserte, or enter one manually."""
assert self._selected_stop_id is not None
if user_input is not None:
return self._create_stop(
line=user_input[CONF_LINE], stop_id=self._selected_stop_id
)
options = self._selected_stop.desserte if self._selected_stop else []
schema = vol.Schema(
{
vol.Required(CONF_LINE): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
custom_value=True,
)
)
}
)
return self.async_show_form(step_id="pick_line", data_schema=schema)
async def _async_load_stops(self) -> str | None:
"""Fetch TCL stops from the API, returning an error key on failure."""
entry = self._get_entry()
session = async_get_clientsession(self.hass)
client = DataGrandLyonClient(
session=session,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
try:
self._stops = await client.get_tcl_stops()
except ClientResponseError as err:
if err.status in (401, 403):
return "invalid_auth"
return "cannot_connect"
except ClientError, TimeoutError:
return "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error fetching Data Grand Lyon TCL stops")
return "unknown"
return None
def _create_stop(self, line: str, stop_id: int) -> SubentryFlowResult:
"""Create the stop subentry, aborting on duplicate."""
entry = self._get_entry()
unique_id = f"{line}_{stop_id}"
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=f"{line} - Stop {stop_id}",
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
unique_id=unique_id,
)
def _stop_label(stop: TclStop) -> str:
label = stop.nom
# variable extracted to please codespell.
address = stop.adresse # codespell:ignore adresse
if address or stop.commune:
label += " (" + ", ".join(filter(None, [address, stop.commune])) + ")"
label += f" - {stop.id}"
return label
class VelovStationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Vélo'v station."""
@@ -46,17 +46,30 @@
"config_subentries": {
"stop": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"entry_type": "Transit stop",
"error": {
"invalid_stop_id": "Stop ID must be a number."
},
"initiate_flow": {
"user": "Add transit stop"
},
"step": {
"pick_line": {
"data": {
"line": "Line"
}
},
"user": {
"data": {
"line": "Line",
"stop_id": "Stop ID"
"stop_id": "Stop"
},
"data_description": {
"stop_id": "Search by stop name, address or city, or enter a stop ID directly."
}
}
}
@@ -54,10 +54,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version < 2:
new_options = {**config_entry.options}
+2 -6
View File
@@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
tcp_port=entry.options[CONF_PORT],
udp_port=entry.options[CONF_PORT],
)
queries.append(resolver_ipv4.query(hostname, "A"))
queries.append(resolver_ipv4.query_dns(hostname, "A"))
if entry.data[CONF_IPV6]:
resolver_ipv6 = aiodns.DNSResolver(
@@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
tcp_port=entry.options[CONF_PORT_IPV6],
udp_port=entry.options[CONF_PORT_IPV6],
)
queries.append(resolver_ipv6.query(hostname, "AAAA"))
queries.append(resolver_ipv6.query_dns(hostname, "AAAA"))
async def _close_resolvers() -> None:
if resolver_ipv4 is not None:
@@ -111,10 +111,6 @@ async def async_migrate_entry(
) -> bool:
"""Migrate old entry to a newer version."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version < 2 and config_entry.minor_version < 2:
_LOGGER.debug(
"Migrating configuration from version %s.%s",
@@ -72,7 +72,7 @@ async def async_validate_hostname(
_resolver = aiodns.DNSResolver(
nameservers=[resolver], udp_port=port, tcp_port=port
)
result = bool(await _resolver.query(hostname, qtype))
result = bool(await _resolver.query_dns(hostname, qtype))
return result
+15 -4
View File
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Literal
import aiodns
from aiodns.error import DNSError
import pycares
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import CONF_NAME, CONF_PORT
@@ -148,7 +149,7 @@ class WanIpSensor(SensorEntity):
response = None
try:
async with asyncio.timeout(10):
response = await self._resolver.query(self.hostname, self.querytype)
response = await self._resolver.query_dns(self.hostname, self.querytype)
except TimeoutError as err:
_LOGGER.debug("Timeout while resolving host: %s", err)
await self._resolver.close()
@@ -157,9 +158,19 @@ class WanIpSensor(SensorEntity):
await self._resolver.close()
if response:
sorted_ips = sort_ips(
[res.host for res in response], querytype=self.querytype
)
if TYPE_CHECKING:
assert all(
isinstance(res.data, (pycares.ARecordData, pycares.AAAARecordData))
for res in response.answer
)
_ips = []
for res in response.answer:
if TYPE_CHECKING:
assert isinstance(
res.data, (pycares.ARecordData, pycares.AAAARecordData)
)
_ips.append(res.data.addr)
sorted_ips = sort_ips(_ips, querytype=self.querytype)
self._attr_native_value = sorted_ips[0]
self._attr_extra_state_attributes["ip_addresses"] = sorted_ips
self._attr_available = True
+1
View File
@@ -7,3 +7,4 @@ from homeassistant.const import Platform
DOMAIN = "duco"
PLATFORMS = [Platform.FAN, Platform.SENSOR]
SCAN_INTERVAL = timedelta(seconds=10)
BOX_NODE_ID = 1
+8 -2
View File
@@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .const import BOX_NODE_ID, DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
@@ -158,7 +158,13 @@ async def async_setup_entry(
# The firmware removes deregistered RF/wired nodes automatically.
# BSRH box sensors that are physically unplugged from the PCB are
# not deregistered by the firmware and will never appear here as stale.
stale_node_ids = known_nodes - coordinator.data.nodes.keys()
# The BOX node can transiently disappear from the API response, so keep
# node 1 to avoid removing the main controller device.
stale_node_ids = {
node_id
for node_id in known_nodes - coordinator.data.nodes.keys()
if node_id != BOX_NODE_ID
}
if stale_node_ids:
device_reg = dr.async_get(hass)
mac = entry.unique_id
@@ -10,7 +10,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyecobee"],
"requirements": ["python-ecobee-api==0.4.0"],
"requirements": ["python-ecobee-api==0.4.1"],
"single_config_entry": true,
"zeroconf": [
{
@@ -98,10 +98,6 @@ async def async_migrate_entry(
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
new_options = {**config_entry.options}
@@ -8,9 +8,12 @@ from env_canada import ECAirQuality, ECMap, ECWeather
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_STATION
from .const import CONF_STATION, DOMAIN
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData
from .services import async_setup_services
DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5)
DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5)
@@ -19,6 +22,14 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER]
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Environment Canada services."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool:
"""Set up EC as config entry."""
@@ -19,6 +19,9 @@
}
},
"services": {
"get_alerts": {
"service": "mdi:bell-alert"
},
"get_forecasts": {
"service": "mdi:weather-cloudy-clock"
},
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.13.2"]
"requirements": ["env-canada==0.15.0"]
}
@@ -0,0 +1,56 @@
"""Define services for the Environment Canada integration."""
from typing import Any
from env_canada import ECWeather
import voluptuous as vol
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
SERVICE_GET_ALERTS = "get_alerts"
SERVICE_GET_ALERTS_SCHEMA = vol.Schema({vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string})
SNAKE_MAPPING = {
"alertColourLevel": "alert_colour_level",
"expiryTime": "expiry_time",
}
async def _async_get_alerts(call: ServiceCall) -> dict[str, Any]:
"""Return the active alerts."""
entry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
ec: ECWeather | None = entry.runtime_data.weather_coordinator.ec_data
if ec is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_connected",
)
data: dict[str, Any] = ec.alerts
return {
k: [
{SNAKE_MAPPING.get(ik, ik): iv for ik, iv in item.items()}
for item in v["value"]
]
for k, v in data.items()
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Environment Canada integration."""
hass.services.async_register(
DOMAIN,
SERVICE_GET_ALERTS,
_async_get_alerts,
schema=SERVICE_GET_ALERTS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -1,3 +1,11 @@
get_alerts:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: environment_canada
get_forecasts:
target:
entity:
@@ -112,7 +112,22 @@
}
}
},
"exceptions": {
"not_connected": {
"message": "Environment Canada is not connected"
}
},
"services": {
"get_alerts": {
"description": "Retrieves the alerts from the selected weather service.",
"fields": {
"config_entry_id": {
"description": "The Environment Canada service to retrieve alerts from.",
"name": "Environment Canada service"
}
},
"name": "Get alerts"
},
"get_forecasts": {
"description": "Retrieves the forecast from selected weather services.",
"name": "Get forecasts"
@@ -88,10 +88,6 @@ async def async_migrate_entry(
config_entry.minor_version,
)
if config_entry.version > 1 or config_entry.minor_version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1 and config_entry.minor_version == 1:
new_data = {**config_entry.data}
new_data[CONF_CONNECTION_TYPE] = HTTP
@@ -53,9 +53,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate config entry."""
if config_entry.version > 2:
# Downgraded from future
return False
if config_entry.version < 2:
# Move optional fields from data to options in config entry
+2
View File
@@ -8,6 +8,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FlussApiClientError, FlussConfigEntry
from .entity import FlussEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
+1 -1
View File
@@ -15,7 +15,7 @@ from .const import DOMAIN
from .coordinator import FlussApiClientError, FlussConfigEntry
from .entity import FlussEntity
PARALLEL_UPDATES = 0
PARALLEL_UPDATES = 1
STATUS_OPEN = "Open"
STATUS_CLOSED = "Closed"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"domain": "fluss",
"name": "Fluss+",
"codeowners": ["@fluss"],
"codeowners": ["@fluss", "@Marcello17"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fluss",
"iot_class": "cloud_polling",
@@ -28,7 +28,7 @@ rules:
docs-installation-parameters: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
@@ -48,8 +48,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> b
async def async_migrate_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
return False
if entry.version == 1:
new_data = {**entry.data}
@@ -65,10 +65,6 @@ async def async_migrate_entry(
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version < 2:
new = {**config_entry.data}
@@ -59,10 +59,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 1:
# Migrate to advanced section
new_options = {**entry.options}
@@ -148,9 +148,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
options = {**config_entry.options}
if config_entry.minor_version < 2:
@@ -76,9 +76,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
options = {**config_entry.options}
if config_entry.minor_version < 2:
@@ -98,10 +98,6 @@ async def async_migrate_entry(
) -> bool:
"""Migrate old config entries."""
if config_entry.version > 2:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
# Update from version 1 to version 2 adding the PROTOCOL to the config entry
host = config_entry.data[CONF_HOST]
@@ -226,10 +226,6 @@ async def async_migrate_entry(
"""Migrate entry."""
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 2 and entry.minor_version == 1:
# Add TTS subentry which was missing in 2025.7.0b0
if not any(
@@ -100,9 +100,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
options = {**config_entry.options}
if config_entry.minor_version < 2:
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.98", "babel==2.15.0"]
"requirements": ["holidays==0.98", "babel==2.18.0"]
}
@@ -108,10 +108,6 @@ async def async_migrate_entry(
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version == 1:
serial_number = config_entry.data[SERIAL_NUMBER]
@@ -135,7 +131,24 @@ async def async_migrate_entry(
)
if canonical.entry_id != config_entry.entry_id:
# The canonical entry's migration will remove this duplicate.
if canonical.minor_version < 2:
# The canonical entry has not been migrated yet and its
# migration will remove this duplicate.
return False
# The canonical entry is already fully migrated and will not run
# a migration that removes this duplicate, so remove it here. The
# entry can't remove itself while its setup lock is held, so
# schedule the removal instead.
_LOGGER.debug(
"Removing duplicate config entry %s for serial %s in favor of %s",
config_entry.entry_id,
serial_number,
canonical.entry_id,
)
hass.async_create_task(
hass.config_entries.async_remove(config_entry.entry_id)
)
return False
for duplicate in duplicates:
@@ -140,10 +140,6 @@ async def async_migrate_entry(
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version == 1:
# Add-on startup with type service get started before
@@ -239,7 +235,24 @@ async def async_migrate_entry(
)
if canonical.entry_id != config_entry.entry_id:
# The canonical entry's migration will remove this duplicate.
if canonical.minor_version < 5:
# The canonical entry has not been migrated yet and its
# migration will remove this duplicate.
return False
# The canonical entry is already fully migrated and will not run
# a migration that removes this duplicate, so remove it here. The
# entry can't remove itself while its setup lock is held, so
# schedule the removal instead.
_LOGGER.warning(
"Removing duplicate config entry %s for serial %s in favor of %s",
config_entry.entry_id,
serial_number,
canonical.entry_id,
)
hass.async_create_task(
hass.config_entries.async_remove(config_entry.entry_id)
)
return False
for duplicate in duplicates:
@@ -46,7 +46,7 @@ class DeviceTriggerAccessory(HomeAccessory):
ent_reg = er.async_get(self.hass)
for idx, trigger in enumerate(device_triggers):
type_: str = trigger["type"]
subtype: str | None = trigger.get("subtype")
subtype: str | int | None = trigger.get("subtype")
unique_id = f"{type_}-{subtype or ''}"
entity_id: str | None = None
if (entity_id_or_uuid := trigger.get("entity_id")) and (
@@ -61,7 +61,7 @@ class DeviceTriggerAccessory(HomeAccessory):
trigger_name_parts.append(state.name)
trigger_name_parts.append(type_.replace("_", " ").title())
if subtype:
trigger_name_parts.append(subtype.replace("_", " ").title())
trigger_name_parts.append(str(subtype).replace("_", " ").title())
trigger_name = cleanup_name_for_homekit(" ".join(trigger_name_parts))
serv_stateless_switch = self.add_preload_service(
SERV_STATELESS_PROGRAMMABLE_SWITCH,
@@ -126,8 +126,6 @@ async def async_migrate_entry(
hass: HomeAssistant, config_entry: config_entries.ConfigEntry
) -> bool:
"""Migrate the config entry from version 1 to version 2."""
if config_entry.version > 2:
return False
if config_entry.version == 1:
_LOGGER.debug("Migrating HomematicIP Cloud config entry to version 2")
@@ -79,9 +79,8 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
data = await self.api.combined()
except RequestError as ex:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise UpdateFailed(
ex, translation_domain=DOMAIN, translation_key="communication_error"
translation_domain=DOMAIN, translation_key="communication_error"
) from ex
except DisabledError as ex:
@@ -96,9 +95,8 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
self.config_entry.entry_id
)
# pylint: disable-next=home-assistant-exception-message-with-translation
raise UpdateFailed(
ex, translation_domain=DOMAIN, translation_key="api_disabled"
translation_domain=DOMAIN, translation_key="api_disabled"
) from ex
except UnauthorizedError as ex:
@@ -1,9 +1,16 @@
"""The coordinator for Hypontech Cloud integration."""
import asyncio
from dataclasses import dataclass
from datetime import timedelta
from hyponcloud import HyponCloud, OverviewData, PlantData, RequestError
from hyponcloud import (
HyponCloud,
OverviewData,
PlantData,
PlantMonitorData,
RequestError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -12,12 +19,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER
@dataclass
class HypontechPlant:
"""Store a plant together with its real-time monitor data."""
info: PlantData
monitor: PlantMonitorData
@dataclass
class HypontechCoordinatorData:
"""Store coordinator data."""
overview: OverviewData
plants: dict[str, PlantData]
plants: dict[str, HypontechPlant]
type HypontechConfigEntry = ConfigEntry[HypontechDataCoordinator]
@@ -50,11 +65,17 @@ class HypontechDataCoordinator(DataUpdateCoordinator[HypontechCoordinatorData]):
try:
overview = await self.api.get_overview()
plants = await self.api.get_list()
monitors = await asyncio.gather(
*(self.api.get_monitor(plant.plant_id) for plant in plants)
)
except RequestError as ex:
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="connection_error"
) from ex
return HypontechCoordinatorData(
overview=overview,
plants={plant.plant_id: plant for plant in plants},
plants={
plant.plant_id: HypontechPlant(info=plant, monitor=monitor)
for plant, monitor in zip(plants, monitors, strict=True)
},
)
+4 -5
View File
@@ -1,12 +1,10 @@
"""Base entity for the Hypontech Cloud integration."""
from hyponcloud import PlantData
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import HypontechDataCoordinator
from .coordinator import HypontechDataCoordinator, HypontechPlant
class HypontechEntity(CoordinatorEntity[HypontechDataCoordinator]):
@@ -36,12 +34,13 @@ class HypontechPlantEntity(CoordinatorEntity[HypontechDataCoordinator]):
plant = coordinator.data.plants[plant_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, plant_id)},
name=plant.plant_name,
name=plant.info.plant_name,
manufacturer="Hypontech",
model=plant.info.plant_type,
)
@property
def plant(self) -> PlantData:
def plant(self) -> HypontechPlant:
"""Return the plant data."""
return self.coordinator.data.plants[self.plant_id]
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["hyponcloud==0.9.3"]
"requirements": ["hyponcloud==1.0.0"]
}
+66 -13
View File
@@ -11,11 +11,11 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HypontechConfigEntry, HypontechDataCoordinator
from .coordinator import HypontechConfigEntry, HypontechDataCoordinator, HypontechPlant
from .entity import HypontechEntity, HypontechPlantEntity
@@ -36,8 +36,8 @@ class HypontechSensorDescription(SensorEntityDescription):
class HypontechPlantSensorDescription(SensorEntityDescription):
"""Describes Hypontech plant sensor entity."""
value_fn: Callable[[PlantData], float | None]
unit_fn: Callable[[PlantData], str] | None = None
value_fn: Callable[[HypontechPlant], float | None]
unit_fn: Callable[[HypontechPlant], str] | None = None
OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = (
@@ -67,12 +67,24 @@ OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = (
)
PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = (
# Historically keyed "pv_power" when total power was the only reading (no
# battery support). Now it carries the PV-only power from the monitor
# endpoint; the plant endpoint's total power is exposed as "total_power".
HypontechPlantSensorDescription(
key="pv_power",
translation_key="pv_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.power,
unit_fn=_power_unit,
value_fn=lambda c: c.monitor.power_pv,
),
HypontechPlantSensorDescription(
key="total_power",
translation_key="total_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.info.power,
unit_fn=lambda c: _power_unit(c.info),
),
HypontechPlantSensorDescription(
key="lifetime_energy",
@@ -80,7 +92,7 @@ PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda c: c.e_total,
value_fn=lambda c: c.info.e_total,
),
HypontechPlantSensorDescription(
key="today_energy",
@@ -88,7 +100,44 @@ PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda c: c.e_today,
value_fn=lambda c: c.info.e_today,
),
HypontechPlantSensorDescription(
key="load_power",
translation_key="load_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.monitor.power_load,
),
HypontechPlantSensorDescription(
key="grid_power",
translation_key="grid_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.monitor.meter_power,
),
)
# Sensors only added for plants that have a battery (storage) system.
BATTERY_SENSORS: tuple[HypontechPlantSensorDescription, ...] = (
HypontechPlantSensorDescription(
key="battery_power",
translation_key="battery_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
# Positive while the battery is discharging, negative while charging.
value_fn=lambda c: c.monitor.w_cha,
),
HypontechPlantSensorDescription(
key="battery_state_of_charge",
translation_key="battery_state_of_charge",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda c: c.monitor.soc,
),
)
@@ -105,11 +154,15 @@ async def async_setup_entry(
HypontechOverviewSensor(coordinator, desc) for desc in OVERVIEW_SENSORS
]
entities.extend(
HypontechPlantSensor(coordinator, plant_id, desc)
for plant_id in coordinator.data.plants
for desc in PLANT_SENSORS
)
for plant_id, plant in coordinator.data.plants.items():
entities.extend(
HypontechPlantSensor(coordinator, plant_id, desc) for desc in PLANT_SENSORS
)
if plant.info.plant_type.endswith("Storage"):
entities.extend(
HypontechPlantSensor(coordinator, plant_id, desc)
for desc in BATTERY_SENSORS
)
async_add_entities(entities)
@@ -36,11 +36,29 @@
},
"entity": {
"sensor": {
"battery_power": {
"name": "Battery power"
},
"battery_state_of_charge": {
"name": "Battery state of charge"
},
"grid_power": {
"name": "Grid power"
},
"lifetime_energy": {
"name": "Lifetime energy"
},
"load_power": {
"name": "Load power"
},
"pv_power": {
"name": "PV power"
},
"today_energy": {
"name": "Today energy"
},
"total_power": {
"name": "Total power"
}
}
},
@@ -49,9 +49,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
options = {**config_entry.options}
if config_entry.minor_version < 2:
@@ -52,9 +52,6 @@ async def async_migrate_entry(
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
new = {**config_entry.data}
if config_entry.minor_version < 2:
@@ -2,6 +2,7 @@
from typing import Any
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -65,6 +66,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) ->
return True
async def async_migrate_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> bool:
"""Migrate an old config entry."""
if entry.version == 1 and entry.minor_version < 2:
new_data = {**entry.data, CONF_URL: entry.data[CONF_URL].rstrip("/")}
hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2)
return True
async def async_unload_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -46,6 +46,7 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Jellyfin."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the Jellyfin config flow."""
@@ -58,6 +59,8 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
user_input[CONF_URL] = user_input[CONF_URL].rstrip("/")
if self.client_device_id is None:
self.client_device_id = _generate_client_device_id()
@@ -131,10 +131,6 @@ async def async_migrate_entry(
return {"new_unique_id": new_unique_id}
return None
if config_entry.version > 2:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
hass.config_entries.async_update_entry(config_entry, version=2)
@@ -2,8 +2,14 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -13,6 +19,12 @@ _PLATFORMS: list[Platform] = [
]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Kiosker integration."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool:
"""Set up Kiosker from a config entry."""
+13
View File
@@ -8,3 +8,16 @@ POLL_INTERVAL = 15
DEFAULT_SSL = False
DEFAULT_SSL_VERIFY = False
REFRESH_DELAY = 0.5
# Service attribute keys
ATTR_URL = "url"
ATTR_VISIBLE = "visible"
ATTR_TEXT = "text"
ATTR_BACKGROUND = "background"
ATTR_FOREGROUND = "foreground"
ATTR_EXPIRE = "expire"
ATTR_DISMISSIBLE = "dismissible"
ATTR_BUTTON_BACKGROUND = "button_background"
ATTR_BUTTON_FOREGROUND = "button_foreground"
ATTR_BUTTON_TEXT = "button_text"
ATTR_SOUND = "sound"
@@ -65,5 +65,13 @@
"default": "mdi:power-sleep"
}
}
},
"services": {
"navigate_url": {
"service": "mdi:web"
},
"set_blackout": {
"service": "mdi:monitor-off"
}
}
}
@@ -1,23 +1,17 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not provide custom actions to document
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration is polling-only and does not subscribe to external events
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
@@ -26,9 +20,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not provide custom actions
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
@@ -0,0 +1,168 @@
"""Services for the Kiosker integration."""
from collections.abc import Awaitable, Callable, Coroutine
import functools
from typing import Any
from kiosker import (
AuthenticationError,
BadRequestError,
Blackout,
ConnectionError,
IPAuthenticationError,
TLSVerificationError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID, ATTR_ICON
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
selector,
)
from .const import (
ATTR_BACKGROUND,
ATTR_BUTTON_BACKGROUND,
ATTR_BUTTON_FOREGROUND,
ATTR_BUTTON_TEXT,
ATTR_DISMISSIBLE,
ATTR_EXPIRE,
ATTR_FOREGROUND,
ATTR_SOUND,
ATTR_TEXT,
ATTR_URL,
ATTR_VISIBLE,
DOMAIN,
)
from .coordinator import KioskerDataUpdateCoordinator
NAVIGATE_URL_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_URL): str,
}
)
SET_BLACKOUT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Optional(ATTR_VISIBLE, default=True): cv.boolean,
vol.Optional(ATTR_TEXT): str,
vol.Optional(ATTR_BACKGROUND, default=[0, 0, 0]): selector.ColorRGBSelector(),
vol.Optional(
ATTR_FOREGROUND, default=[255, 255, 255]
): selector.ColorRGBSelector(),
vol.Optional(ATTR_ICON): str,
vol.Optional(ATTR_EXPIRE, default=60): vol.All(
vol.Coerce(int), vol.Range(min=0, max=100000)
),
vol.Optional(ATTR_DISMISSIBLE, default=False): cv.boolean,
vol.Optional(
ATTR_BUTTON_BACKGROUND, default=[255, 255, 255]
): selector.ColorRGBSelector(),
vol.Optional(
ATTR_BUTTON_FOREGROUND, default=[0, 0, 0]
): selector.ColorRGBSelector(),
vol.Optional(ATTR_BUTTON_TEXT): str,
vol.Optional(ATTR_SOUND): str,
}
)
def handle_kiosker_api_errors(
func: Callable[[ServiceCall], Awaitable[None]],
) -> Callable[[ServiceCall], Coroutine[Any, Any, ServiceResponse]]:
"""Decorator to handle Kiosker API errors consistently across all service calls."""
@functools.wraps(func)
async def wrapper(call: ServiceCall) -> ServiceResponse:
try:
await func(call)
except ConnectionError as ex:
raise HomeAssistantError(f"Unable to connect to Kiosker: {ex}") from ex
except AuthenticationError as ex:
raise ServiceValidationError(
"Authentication failed. Check your API token."
) from ex
except IPAuthenticationError as ex:
raise ServiceValidationError(
"IP authentication failed. Check your IP whitelist."
) from ex
except TLSVerificationError as ex:
raise ServiceValidationError(f"TLS verification failed: {ex}") from ex
except BadRequestError as ex:
raise ServiceValidationError(f"Bad request: {ex}") from ex
else:
return None
return wrapper
async def _get_coordinator(
call: ServiceCall,
) -> KioskerDataUpdateCoordinator:
"""Get the coordinator for the targeted device."""
registry = dr.async_get(call.hass)
device_id: str = call.data[ATTR_DEVICE_ID]
device = registry.async_get(device_id)
if device:
for entry_id in device.config_entries:
entry = call.hass.config_entries.async_get_entry(entry_id)
if entry and entry.domain == DOMAIN:
if entry.state != ConfigEntryState.LOADED:
raise HomeAssistantError(f"{entry.title} is not loaded")
return entry.runtime_data
raise ServiceValidationError(f"No {DOMAIN} devices found in targeted selection")
def _rgb_to_hex(rgb: list[int]) -> str:
"""Convert an [r, g, b] list to a hex color string."""
return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
@handle_kiosker_api_errors
async def navigate_url(call: ServiceCall) -> None:
"""Navigate to a URL on the Kiosker device."""
coordinator = await _get_coordinator(call)
await call.hass.async_add_executor_job(
coordinator.api.navigate_url, call.data[ATTR_URL]
)
@handle_kiosker_api_errors
async def set_blackout(call: ServiceCall) -> None:
"""Set blackout mode on the Kiosker device."""
blackout = Blackout(
visible=call.data[ATTR_VISIBLE],
text=call.data.get(ATTR_TEXT),
background=_rgb_to_hex(call.data[ATTR_BACKGROUND]),
foreground=_rgb_to_hex(call.data[ATTR_FOREGROUND]),
icon=call.data.get(ATTR_ICON),
expire=call.data[ATTR_EXPIRE],
dismissible=call.data[ATTR_DISMISSIBLE],
buttonBackground=_rgb_to_hex(call.data[ATTR_BUTTON_BACKGROUND]),
buttonForeground=_rgb_to_hex(call.data[ATTR_BUTTON_FOREGROUND]),
buttonText=call.data.get(ATTR_BUTTON_TEXT),
sound=call.data.get(ATTR_SOUND),
)
coordinator = await _get_coordinator(call)
await call.hass.async_add_executor_job(coordinator.api.blackout_set, blackout)
await coordinator.async_request_refresh()
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Kiosker integration."""
hass.services.async_register(
DOMAIN, "navigate_url", navigate_url, schema=NAVIGATE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, "set_blackout", set_blackout, schema=SET_BLACKOUT_SCHEMA
)
@@ -0,0 +1,62 @@
navigate_url:
fields:
device_id:
required: true
selector:
device:
integration: kiosker
url:
required: true
selector:
text:
set_blackout:
fields:
device_id:
required: true
selector:
device:
integration: kiosker
visible:
default: true
selector:
boolean:
text:
selector:
text:
background:
default: [0, 0, 0]
selector:
color_rgb:
foreground:
default: [255, 255, 255]
selector:
color_rgb:
icon:
selector:
text:
expire:
default: 60
selector:
number:
min: 0
max: 3600
unit_of_measurement: seconds
dismissible:
default: false
selector:
boolean:
button_background:
default: [255, 255, 255]
selector:
color_rgb:
button_foreground:
default: [0, 0, 0]
selector:
color_rgb:
button_text:
selector:
text:
sound:
selector:
text:
@@ -104,5 +104,75 @@
"name": "Disable screensaver"
}
}
},
"services": {
"navigate_url": {
"description": "Navigate to a specific URL",
"fields": {
"device_id": {
"description": "The Kiosker device to control",
"name": "Device"
},
"url": {
"description": "The URL to navigate to",
"name": "URL"
}
},
"name": "Navigate to URL"
},
"set_blackout": {
"description": "Set blackout screen with custom message",
"fields": {
"background": {
"description": "Background color in rgb format",
"name": "Background color"
},
"button_background": {
"description": "Background color of the dismiss button in rgb format",
"name": "Button background color"
},
"button_foreground": {
"description": "Text color of the dismiss button in rgb format",
"name": "Button foreground color"
},
"button_text": {
"description": "Text to display on the dismiss button",
"name": "Button text"
},
"device_id": {
"description": "The Kiosker device to control",
"name": "Device"
},
"dismissible": {
"description": "Whether the blackout can be dismissed by user interaction",
"name": "Dismissible"
},
"expire": {
"description": "Time in seconds before the blackout expires",
"name": "Expire time"
},
"foreground": {
"description": "Text color in rgb format",
"name": "Foreground color"
},
"icon": {
"description": "Icon to display (SF Symbols name)",
"name": "Icon"
},
"sound": {
"description": "Sound to play when blackout is displayed (SystemSoundID, e.g., 1007)",
"name": "Sound"
},
"text": {
"description": "Text to display on blackout screen",
"name": "Text"
},
"visible": {
"description": "Whether the blackout is visible",
"name": "Visible"
}
},
"name": "Set blackout"
}
}
}
@@ -217,9 +217,6 @@ async def async_migrate_entry(
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
) -> bool:
"""Migrate config entry."""
if entry.version > 4:
# guard against downgrade from a future version
return False
if entry.version in (1, 2):
_LOGGER.error(
@@ -735,6 +735,7 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
value = self.data.value
if isinstance(value, time):
# pylint: disable-next=home-assistant-enforce-now
local_now = datetime.now(
tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)
@@ -847,6 +848,7 @@ class ThinQEnergySensorEntity(ThinQEntity, SensorEntity):
async def _async_update_and_schedule(self) -> None:
"""Update the state of the sensor."""
# pylint: disable-next=home-assistant-enforce-now
local_now = datetime.now(
dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)

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