mirror of
https://github.com/home-assistant/core.git
synced 2025-09-24 04:19:33 +00:00
Compare commits
158 Commits
standardiz
...
add-includ
Author | SHA1 | Date | |
---|---|---|---|
![]() |
53f20502c5 | ||
![]() |
8b984a2105 | ||
![]() |
ebee370a56 | ||
![]() |
dabd096587 | ||
![]() |
21399818af | ||
![]() |
4354214fbf | ||
![]() |
5bd39804f1 | ||
![]() |
6d3ad3ab9c | ||
![]() |
4c212bdcd4 | ||
![]() |
b91b39580f | ||
![]() |
472d70b6c9 | ||
![]() |
017a84a859 | ||
![]() |
d184540967 | ||
![]() |
1740984b3b | ||
![]() |
4db8592c61 | ||
![]() |
27e630c107 | ||
![]() |
ea8833342d | ||
![]() |
87be2ba823 | ||
![]() |
51c35eb631 | ||
![]() |
24a86d042f | ||
![]() |
cd6f653123 | ||
![]() |
fd05ddca28 | ||
![]() |
a1f2eb44ae | ||
![]() |
c4ddc03dbc | ||
![]() |
9db5aafb71 | ||
![]() |
64cdcfb613 | ||
![]() |
c761ce699c | ||
![]() |
40ebce4ae8 | ||
![]() |
29914d6722 | ||
![]() |
5eef6edded | ||
![]() |
db729273a5 | ||
![]() |
946d75d651 | ||
![]() |
093f779edb | ||
![]() |
87658e77a7 | ||
![]() |
38f65cda98 | ||
![]() |
797c6ddedd | ||
![]() |
fe8a53407a | ||
![]() |
ae5f57fd99 | ||
![]() |
a93c3cc23c | ||
![]() |
804b42e1fb | ||
![]() |
a4f15e4840 | ||
![]() |
2471177c84 | ||
![]() |
a494d3ec69 | ||
![]() |
b10a9721a7 | ||
![]() |
04c0bb20d6 | ||
![]() |
1598c4ebe8 | ||
![]() |
d67ec7593a | ||
![]() |
4a4c124181 | ||
![]() |
c34af4be86 | ||
![]() |
823071b722 | ||
![]() |
462fa77ba1 | ||
![]() |
24fc8b9297 | ||
![]() |
2596ab2940 | ||
![]() |
23fa84e20e | ||
![]() |
7f13141297 | ||
![]() |
770f41d079 | ||
![]() |
df16e85359 | ||
![]() |
3c6db923a3 | ||
![]() |
450c47f932 | ||
![]() |
048f64eccf | ||
![]() |
c4c523e8b7 | ||
![]() |
87e30e0907 | ||
![]() |
74660da2d2 | ||
![]() |
6b8c180509 | ||
![]() |
eb4a873c43 | ||
![]() |
6aafa666d6 | ||
![]() |
9ee9bb368d | ||
![]() |
6e4258c8a9 | ||
![]() |
d65e704823 | ||
![]() |
aadaf87c16 | ||
![]() |
e70b147c0c | ||
![]() |
031b12752f | ||
![]() |
df0cfd69a9 | ||
![]() |
b2c53f2d78 | ||
![]() |
3649e949b1 | ||
![]() |
de7e2303a7 | ||
![]() |
892f3f267b | ||
![]() |
0254285285 | ||
![]() |
44a95242dc | ||
![]() |
f9b1c52d65 | ||
![]() |
aa8d78622c | ||
![]() |
ca6289a576 | ||
![]() |
0f372f4b47 | ||
![]() |
4bba167ab3 | ||
![]() |
962c0c443d | ||
![]() |
c6b4cac28a | ||
![]() |
3c7e3a5e30 | ||
![]() |
fa698956c3 | ||
![]() |
32f136b12f | ||
![]() |
e1f617df25 | ||
![]() |
84f1b8a5cc | ||
![]() |
e9cedf4852 | ||
![]() |
9c72b40ab4 | ||
![]() |
65f655e5f5 | ||
![]() |
af28573894 | ||
![]() |
c5fc1de3df | ||
![]() |
1df1144eb9 | ||
![]() |
d51c0e3752 | ||
![]() |
f5157878c2 | ||
![]() |
fb723571b6 | ||
![]() |
dbf80c3ce3 | ||
![]() |
e0a774b598 | ||
![]() |
168afc5f0e | ||
![]() |
af23670854 | ||
![]() |
935ce421df | ||
![]() |
c60ad8179d | ||
![]() |
14ad3364e3 | ||
![]() |
e229f36648 | ||
![]() |
f4f99e015c | ||
![]() |
5dc509cba0 | ||
![]() |
75597ac98d | ||
![]() |
b503f792b5 | ||
![]() |
410c3df6dd | ||
![]() |
f1bf28df18 | ||
![]() |
99fb64af9b | ||
![]() |
c0af0159e3 | ||
![]() |
71749da3a3 | ||
![]() |
b01be94034 | ||
![]() |
47ec8b7f12 | ||
![]() |
93ec9e448e | ||
![]() |
90bc41dd02 | ||
![]() |
410d869f3d | ||
![]() |
d75d9f2589 | ||
![]() |
afbb832a57 | ||
![]() |
bdc881c87a | ||
![]() |
22ea269ed8 | ||
![]() |
10fecbaf4d | ||
![]() |
cbdc1dc5b6 | ||
![]() |
b203a831c9 | ||
![]() |
5ccbee4c9a | ||
![]() |
1483c9488f | ||
![]() |
f5535db24c | ||
![]() |
e40ecdfb00 | ||
![]() |
2f4c69bbd5 | ||
![]() |
dd0f6a702b | ||
![]() |
5ba580bc25 | ||
![]() |
c13002bdd5 | ||
![]() |
75d22191a0 | ||
![]() |
58d6549f1c | ||
![]() |
1fcc6df1fd | ||
![]() |
9bf467e6d1 | ||
![]() |
d877d6d93f | ||
![]() |
d2b255ba92 | ||
![]() |
1509c429d6 | ||
![]() |
af9717c1cd | ||
![]() |
49e75c9cf8 | ||
![]() |
c97f16a96d | ||
![]() |
a3a4433d62 | ||
![]() |
f832002afd | ||
![]() |
dbc7f2b43c | ||
![]() |
1cd3a1eede | ||
![]() |
7d6e0d44b0 | ||
![]() |
2bb6d745ca | ||
![]() |
beb9d7856c | ||
![]() |
6a4c8a550a | ||
![]() |
7d23752a3f | ||
![]() |
c2b2a78db5 | ||
![]() |
0fb6bbee59 |
77
.claude/agents/quality-scale-rule-verifier.md
Normal file
77
.claude/agents/quality-scale-rule-verifier.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: quality-scale-rule-verifier
|
||||
description: |
|
||||
Use this agent when you need to verify that a Home Assistant integration follows a specific quality scale rule. This includes checking if the integration implements required patterns, configurations, or code structures defined by the quality scale system.
|
||||
|
||||
<example>
|
||||
Context: The user wants to verify if an integration follows a specific quality scale rule.
|
||||
user: "Check if the peblar integration follows the config-flow rule"
|
||||
assistant: "I'll use the quality scale rule verifier to check if the peblar integration properly implements the config-flow rule."
|
||||
<commentary>
|
||||
Since the user is asking to verify a quality scale rule implementation, use the quality-scale-rule-verifier agent.
|
||||
</commentary>
|
||||
</example>
|
||||
|
||||
<example>
|
||||
Context: The user is reviewing if an integration reaches a specific quality scale level.
|
||||
user: "Verify that this integration reaches the bronze quality scale"
|
||||
assistant: "Let me use the quality scale rule verifier to check the bronze quality scale implementation."
|
||||
<commentary>
|
||||
The user wants to verify the integration has reached a certain quality level, so use multiple quality-scale-rule-verifier agents to verify each bronze rule.
|
||||
</commentary>
|
||||
</example>
|
||||
model: inherit
|
||||
color: yellow
|
||||
tools: Read, Bash, Grep, Glob, WebFetch
|
||||
---
|
||||
|
||||
You are an expert Home Assistant integration quality scale auditor specializing in verifying compliance with specific quality scale rules. You have deep knowledge of Home Assistant's architecture, best practices, and the quality scale system that ensures integration consistency and reliability.
|
||||
|
||||
You will verify if an integration follows a specific quality scale rule by:
|
||||
|
||||
1. **Fetching Rule Documentation**: Retrieve the official rule documentation from:
|
||||
`https://raw.githubusercontent.com/home-assistant/developers.home-assistant/refs/heads/master/docs/core/integration-quality-scale/rules/{rule_name}.md`
|
||||
where `{rule_name}` is the rule identifier (e.g., 'config-flow', 'entity-unique-id', 'parallel-updates')
|
||||
|
||||
2. **Understanding Rule Requirements**: Parse the rule documentation to identify:
|
||||
- Core requirements and mandatory implementations
|
||||
- Specific code patterns or configurations required
|
||||
- Common violations and anti-patterns
|
||||
- Exemption criteria (when a rule might not apply)
|
||||
- The quality tier this rule belongs to (Bronze, Silver, Gold, Platinum)
|
||||
|
||||
3. **Analyzing Integration Code**: Examine the integration's codebase at `homeassistant/components/<integration domain>` focusing on:
|
||||
- `manifest.json` for quality scale declaration and configuration
|
||||
- `quality_scale.yaml` for rule status (done, todo, exempt)
|
||||
- Relevant Python modules based on the rule requirements
|
||||
- Configuration files and service definitions as needed
|
||||
|
||||
4. **Verification Process**:
|
||||
- Check if the rule is marked as 'done', 'todo', or 'exempt' in quality_scale.yaml
|
||||
- If marked 'exempt', verify the exemption reason is valid
|
||||
- If marked 'done', verify the actual implementation matches requirements
|
||||
- Identify specific files and code sections that demonstrate compliance or violations
|
||||
- Consider the integration's declared quality tier when applying rules
|
||||
- To fetch the integration docs, use WebFetch to fetch from `https://raw.githubusercontent.com/home-assistant/home-assistant.io/refs/heads/current/source/_integrations/<integration domain>.markdown`
|
||||
- To fetch information about a PyPI package, use the URL `https://pypi.org/pypi/<package>/json`
|
||||
|
||||
5. **Reporting Findings**: Provide a comprehensive verification report that includes:
|
||||
- **Rule Summary**: Brief description of what the rule requires
|
||||
- **Compliance Status**: Clear pass/fail/exempt determination
|
||||
- **Evidence**: Specific code examples showing compliance or violations
|
||||
- **Issues Found**: Detailed list of any non-compliance issues with file locations
|
||||
- **Recommendations**: Actionable steps to achieve compliance if needed
|
||||
- **Exemption Analysis**: If applicable, whether the exemption is justified
|
||||
|
||||
When examining code, you will:
|
||||
- Look for exact implementation patterns specified in the rule
|
||||
- Verify all required components are present and properly configured
|
||||
- Check for common mistakes and anti-patterns
|
||||
- Consider edge cases and error handling requirements
|
||||
- Validate that implementations follow Home Assistant conventions
|
||||
|
||||
You will be thorough but focused, examining only the aspects relevant to the specific rule being verified. You will provide clear, actionable feedback that helps developers understand both what needs to be fixed and why it matters for integration quality.
|
||||
|
||||
If you cannot access the rule documentation or find the integration code, clearly state what information is missing and what you would need to complete the verification.
|
||||
|
||||
Remember that quality scale rules are cumulative - Bronze rules apply to all integrations with a quality scale, Silver rules apply to Silver+ integrations, and so on. Always consider the integration's target quality level when determining which rules should be enforced.
|
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -55,8 +55,12 @@
|
||||
creating the PR. If you're unsure about any of them, don't hesitate to ask.
|
||||
We're here to help! This is simply a reminder of what we are going to look
|
||||
for before merging your code.
|
||||
|
||||
AI tools are welcome, but contributors are responsible for *fully*
|
||||
understanding the code before submitting a PR.
|
||||
-->
|
||||
|
||||
- [ ] I understand the code I am submitting and can explain how it works.
|
||||
- [ ] The code change is tested and works locally.
|
||||
- [ ] Local tests pass. **Your PR cannot be merged unless tests pass**
|
||||
- [ ] There is no commented out code in this PR.
|
||||
@@ -64,6 +68,7 @@
|
||||
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
|
||||
- [ ] The code has been formatted using Ruff (`ruff format homeassistant tests`)
|
||||
- [ ] Tests have been added to verify that the new code works.
|
||||
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
|
||||
|
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -196,8 +196,9 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@71885366c80f6ead6ae8c364b61d910e0dc5addc # 2025.03.0
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -262,8 +263,9 @@ jobs:
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@71885366c80f6ead6ae8c364b61d910e0dc5addc # 2025.03.0
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -324,7 +326,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
|
25
.github/workflows/ci.yaml
vendored
25
.github/workflows/ci.yaml
vendored
@@ -523,22 +523,24 @@ jobs:
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{
|
||||
env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{
|
||||
env.HA_SHORT_VERSION }}-
|
||||
- name: Restore apt cache
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
id: cache-apt
|
||||
uses: actions/cache@v4.2.4
|
||||
- name: Check if apt cache exists
|
||||
id: cache-apt-check
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }}
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Install additional OS dependencies
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
if: |
|
||||
steps.cache-venv.outputs.cache-hit != 'true'
|
||||
|| steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo rm /etc/apt/sources.list.d/microsoft-prod.list
|
||||
if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
|
||||
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
|
||||
mkdir -p ${{ env.APT_CACHE_DIR }}
|
||||
mkdir -p ${{ env.APT_LIST_CACHE_DIR }}
|
||||
fi
|
||||
@@ -563,9 +565,18 @@ jobs:
|
||||
libswscale-dev \
|
||||
libudev-dev
|
||||
|
||||
if [[ "${{ steps.cache-apt.outputs.cache-hit }}" != 'true' ]]; then
|
||||
if [[ "${{ steps.cache-apt-check.outputs.cache-hit }}" != 'true' ]]; then
|
||||
sudo chmod -R 755 ${{ env.APT_CACHE_BASE }}
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }}
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -140,5 +140,5 @@ tmp_cache
|
||||
pytest_buckets.txt
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
.claude/settings.local.json
|
||||
|
||||
|
17
CODEOWNERS
generated
17
CODEOWNERS
generated
@@ -107,8 +107,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/ambient_station/ @bachya
|
||||
/tests/components/ambient_station/ @bachya
|
||||
/homeassistant/components/amcrest/ @flacjacket
|
||||
/homeassistant/components/analytics/ @home-assistant/core @ludeeus
|
||||
/tests/components/analytics/ @home-assistant/core @ludeeus
|
||||
/homeassistant/components/analytics/ @home-assistant/core
|
||||
/tests/components/analytics/ @home-assistant/core
|
||||
/homeassistant/components/analytics_insights/ @joostlek
|
||||
/tests/components/analytics_insights/ @joostlek
|
||||
/homeassistant/components/android_ip_webcam/ @engrbm87
|
||||
@@ -1017,7 +1017,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nanoleaf/ @milanmeu @joostlek
|
||||
/homeassistant/components/nasweb/ @nasWebio
|
||||
/tests/components/nasweb/ @nasWebio
|
||||
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM
|
||||
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
|
||||
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
|
||||
/homeassistant/components/ness_alarm/ @nickw444
|
||||
/tests/components/ness_alarm/ @nickw444
|
||||
/homeassistant/components/nest/ @allenporter
|
||||
@@ -1349,6 +1350,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/samsungtv/ @chemelli74 @epenet
|
||||
/homeassistant/components/sanix/ @tomaszsluszniak
|
||||
/tests/components/sanix/ @tomaszsluszniak
|
||||
/homeassistant/components/satel_integra/ @Tommatheussen
|
||||
/tests/components/satel_integra/ @Tommatheussen
|
||||
/homeassistant/components/scene/ @home-assistant/core
|
||||
/tests/components/scene/ @home-assistant/core
|
||||
/homeassistant/components/schedule/ @home-assistant/core
|
||||
@@ -1530,8 +1533,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/switchbee/ @jafar-atili
|
||||
/homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski @zerzhang
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur
|
||||
/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
|
||||
/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur @XiaoLing-git
|
||||
/homeassistant/components/switcher_kis/ @thecode @YogevBokobza
|
||||
/tests/components/switcher_kis/ @thecode @YogevBokobza
|
||||
/homeassistant/components/switchmate/ @danielhiversen @qiz-li
|
||||
@@ -1676,6 +1679,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/uptime_kuma/ @tr4nt0r
|
||||
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
|
||||
/tests/components/uptimerobot/ @ludeeus @chemelli74
|
||||
/homeassistant/components/usage_prediction/ @home-assistant/core
|
||||
/tests/components/usage_prediction/ @home-assistant/core
|
||||
/homeassistant/components/usb/ @bdraco
|
||||
/tests/components/usb/ @bdraco
|
||||
/homeassistant/components/usgs_earthquakes_feed/ @exxamalte
|
||||
@@ -1705,6 +1710,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
|
||||
/homeassistant/components/vicare/ @CFenner
|
||||
/tests/components/vicare/ @CFenner
|
||||
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
|
||||
/tests/components/victron_remote_monitoring/ @AndyTempel
|
||||
/homeassistant/components/vilfo/ @ManneW
|
||||
/tests/components/vilfo/ @ManneW
|
||||
/homeassistant/components/vivotek/ @HarlemSquirrel
|
||||
|
@@ -2,21 +2,31 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.media_source import MediaSource, local_source
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR
|
||||
|
||||
|
||||
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||
"""Set up local media source."""
|
||||
media_dir = hass.config.path(f"{DOMAIN}/{IMAGE_DIR}")
|
||||
media_dirs = list(hass.config.media_dirs.values())
|
||||
|
||||
if not media_dirs:
|
||||
raise HomeAssistantError(
|
||||
"AI Task media source requires at least one media directory configured"
|
||||
)
|
||||
|
||||
media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR
|
||||
|
||||
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"AI Generated Images",
|
||||
{IMAGE_DIR: media_dir},
|
||||
{IMAGE_DIR: str(media_dir)},
|
||||
f"/{DOMAIN}",
|
||||
)
|
||||
return source
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import io
|
||||
@@ -13,9 +12,9 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import camera, conversation, media_source
|
||||
from homeassistant.components import camera, conversation, image, media_source
|
||||
from homeassistant.components.http.auth import async_sign_path
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session
|
||||
@@ -32,32 +31,61 @@ from .const import (
|
||||
)
|
||||
|
||||
|
||||
def _save_camera_snapshot(image: camera.Image) -> Path:
|
||||
def _save_camera_snapshot(image_data: camera.Image | image.Image) -> Path:
|
||||
"""Save camera snapshot to temp file."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
suffix=mimetypes.guess_extension(image.content_type, False),
|
||||
suffix=mimetypes.guess_extension(image_data.content_type, False),
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_file.write(image.content)
|
||||
temp_file.write(image_data.content)
|
||||
return Path(temp_file.name)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _resolve_attachments(
|
||||
hass: HomeAssistant,
|
||||
session: ChatSession,
|
||||
attachments: list[dict] | None = None,
|
||||
) -> list[conversation.Attachment]:
|
||||
"""Resolve attachments for a task."""
|
||||
async with AsyncExitStack() as stack:
|
||||
resolved_attachments: list[conversation.Attachment] = []
|
||||
resolved_attachments: list[conversation.Attachment] = []
|
||||
created_files: list[Path] = []
|
||||
|
||||
for attachment in attachments or []:
|
||||
media_content_id = attachment["media_content_id"]
|
||||
media = await stack.enter_async_context(
|
||||
media_source.async_resolve_with_path(hass, media_content_id, None)
|
||||
for attachment in attachments or []:
|
||||
media_content_id = attachment["media_content_id"]
|
||||
|
||||
# Special case for certain media sources
|
||||
for integration in camera, image:
|
||||
media_source_prefix = f"media-source://{integration.DOMAIN}/"
|
||||
if not media_content_id.startswith(media_source_prefix):
|
||||
continue
|
||||
|
||||
# Extract entity_id from the media content ID
|
||||
entity_id = media_content_id.removeprefix(media_source_prefix)
|
||||
|
||||
# Get snapshot from entity
|
||||
image_data = await integration.async_get_image(hass, entity_id)
|
||||
|
||||
temp_filename = await hass.async_add_executor_job(
|
||||
_save_camera_snapshot, image_data
|
||||
)
|
||||
created_files.append(temp_filename)
|
||||
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
mime_type=image_data.content_type,
|
||||
path=temp_filename,
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
# Handle regular media sources
|
||||
media = await media_source.async_resolve_media(hass, media_content_id, None)
|
||||
if media.path is None:
|
||||
raise HomeAssistantError(
|
||||
"Only local attachments are currently supported"
|
||||
)
|
||||
resolved_attachments.append(
|
||||
conversation.Attachment(
|
||||
media_content_id=media_content_id,
|
||||
@@ -66,7 +94,22 @@ async def _resolve_attachments(
|
||||
)
|
||||
)
|
||||
|
||||
yield resolved_attachments
|
||||
if not created_files:
|
||||
return resolved_attachments
|
||||
|
||||
def cleanup_files() -> None:
|
||||
"""Cleanup temporary files."""
|
||||
for file in created_files:
|
||||
file.unlink(missing_ok=True)
|
||||
|
||||
@callback
|
||||
def cleanup_files_callback() -> None:
|
||||
"""Cleanup temporary files."""
|
||||
hass.async_add_executor_job(cleanup_files)
|
||||
|
||||
session.async_on_cleanup(cleanup_files_callback)
|
||||
|
||||
return resolved_attachments
|
||||
|
||||
|
||||
async def async_generate_data(
|
||||
@@ -104,19 +147,18 @@ async def async_generate_data(
|
||||
)
|
||||
|
||||
with async_get_chat_session(hass) as session:
|
||||
async with _resolve_attachments(
|
||||
hass, session, attachments
|
||||
) as resolved_attachments:
|
||||
return await entity.internal_async_generate_data(
|
||||
session,
|
||||
GenDataTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
structure=structure,
|
||||
attachments=resolved_attachments or None,
|
||||
llm_api=llm_api,
|
||||
),
|
||||
)
|
||||
resolved_attachments = await _resolve_attachments(hass, session, attachments)
|
||||
|
||||
return await entity.internal_async_generate_data(
|
||||
session,
|
||||
GenDataTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
structure=structure,
|
||||
attachments=resolved_attachments or None,
|
||||
llm_api=llm_api,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_generate_image(
|
||||
@@ -152,17 +194,16 @@ async def async_generate_image(
|
||||
)
|
||||
|
||||
with async_get_chat_session(hass) as session:
|
||||
async with _resolve_attachments(
|
||||
hass, session, attachments
|
||||
) as resolved_attachments:
|
||||
task_result = await entity.internal_async_generate_image(
|
||||
session,
|
||||
GenImageTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
attachments=resolved_attachments or None,
|
||||
),
|
||||
)
|
||||
resolved_attachments = await _resolve_attachments(hass, session, attachments)
|
||||
|
||||
task_result = await entity.internal_async_generate_image(
|
||||
session,
|
||||
GenImageTask(
|
||||
name=task_name,
|
||||
instructions=instructions,
|
||||
attachments=resolved_attachments or None,
|
||||
),
|
||||
)
|
||||
|
||||
service_result = task_result.as_dict()
|
||||
image_data = service_result.pop("image_data")
|
||||
|
@@ -2,7 +2,7 @@
|
||||
"domain": "analytics",
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
|
@@ -467,7 +467,10 @@ async def async_setup_entry(
|
||||
# periodical (or manual) self test since last daemon restart. It might not be available
|
||||
# when we set up the integration, and we do not know if it would ever be available. Here we
|
||||
# add it anyway and mark it as unknown initially.
|
||||
for resource in available_resources | {LAST_S_TEST}:
|
||||
#
|
||||
# We also sort the resources to ensure the order of entities created is deterministic since
|
||||
# "APCMODEL" and "MODEL" resources map to the same "Model" name.
|
||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
continue
|
||||
|
@@ -120,6 +120,7 @@ class AsusWrtBridge(ABC):
|
||||
|
||||
def __init__(self, host: str) -> None:
|
||||
"""Initialize Bridge."""
|
||||
self._configuration_url = f"http://{host}"
|
||||
self._host = host
|
||||
self._firmware: str | None = None
|
||||
self._label_mac: str | None = None
|
||||
@@ -127,6 +128,11 @@ class AsusWrtBridge(ABC):
|
||||
self._model_id: str | None = None
|
||||
self._serial_number: str | None = None
|
||||
|
||||
@property
|
||||
def configuration_url(self) -> str:
|
||||
"""Return configuration URL."""
|
||||
return self._configuration_url
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""Return hostname."""
|
||||
@@ -371,6 +377,7 @@ class AsusWrtHttpBridge(AsusWrtBridge):
|
||||
# get main router properties
|
||||
if mac := _identity.mac:
|
||||
self._label_mac = format_mac(mac)
|
||||
self._configuration_url = self._api.webpanel
|
||||
self._firmware = str(_identity.firmware)
|
||||
self._model = _identity.model
|
||||
self._model_id = _identity.product_id
|
||||
|
@@ -388,13 +388,13 @@ class AsusWrtRouter:
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device information."""
|
||||
info = DeviceInfo(
|
||||
configuration_url=self._api.configuration_url,
|
||||
identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")},
|
||||
name=self.host,
|
||||
model=self._api.model or "Asus Router",
|
||||
model_id=self._api.model_id,
|
||||
serial_number=self._api.serial_number,
|
||||
manufacturer="Asus",
|
||||
configuration_url=f"http://{self.host}",
|
||||
)
|
||||
if self._api.firmware:
|
||||
info["sw_version"] = self._api.firmware
|
||||
|
@@ -29,5 +29,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.0.1", "yalexs-ble==3.1.2"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.1.2"]
|
||||
}
|
||||
|
@@ -92,7 +92,11 @@ from homeassistant.components.http.ban import (
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.components.http.view import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.network import is_cloud_connection
|
||||
from homeassistant.helpers.network import (
|
||||
NoURLAvailableError,
|
||||
get_url,
|
||||
is_cloud_connection,
|
||||
)
|
||||
from homeassistant.util.network import is_local
|
||||
|
||||
from . import indieauth
|
||||
@@ -125,11 +129,18 @@ class WellKnownOAuthInfoView(HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Return the well known OAuth2 authorization info."""
|
||||
hass = request.app[KEY_HASS]
|
||||
# Some applications require absolute urls, so we prefer using the
|
||||
# current requests url if possible, with fallback to a relative url.
|
||||
try:
|
||||
url_prefix = get_url(hass, require_current_request=True)
|
||||
except NoURLAvailableError:
|
||||
url_prefix = ""
|
||||
return self.json(
|
||||
{
|
||||
"authorization_endpoint": "/auth/authorize",
|
||||
"token_endpoint": "/auth/token",
|
||||
"revocation_endpoint": "/auth/revoke",
|
||||
"authorization_endpoint": f"{url_prefix}/auth/authorize",
|
||||
"token_endpoint": f"{url_prefix}/auth/token",
|
||||
"revocation_endpoint": f"{url_prefix}/auth/revoke",
|
||||
"response_types_supported": ["code"],
|
||||
"service_documentation": (
|
||||
"https://developers.home-assistant.io/docs/auth_api"
|
||||
|
@@ -26,7 +26,6 @@ EXCLUDE_FROM_BACKUP = [
|
||||
"tmp_backups/*.tar",
|
||||
"OZW_Log.txt",
|
||||
"tts/*",
|
||||
"ai_task/*",
|
||||
]
|
||||
|
||||
EXCLUDE_DATABASE_FROM_BACKUP = [
|
||||
|
@@ -8,7 +8,7 @@ import threading
|
||||
from typing import IO, cast
|
||||
|
||||
from aiohttp import BodyPartReader
|
||||
from aiohttp.hdrs import CONTENT_DISPOSITION
|
||||
from aiohttp.hdrs import CONTENT_DISPOSITION, CONTENT_TYPE
|
||||
from aiohttp.web import FileResponse, Request, Response, StreamResponse
|
||||
from multidict import istr
|
||||
|
||||
@@ -76,7 +76,8 @@ class DownloadBackupView(HomeAssistantView):
|
||||
return Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
headers = {
|
||||
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
|
||||
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar",
|
||||
CONTENT_TYPE: "application/x-tar",
|
||||
}
|
||||
|
||||
try:
|
||||
|
@@ -14,15 +14,15 @@
|
||||
},
|
||||
"automatic_backup_failed_addons": {
|
||||
"title": "Not all add-ons could be included in automatic backup",
|
||||
"description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
"description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
},
|
||||
"automatic_backup_failed_agents_addons_folders": {
|
||||
"title": "Automatic backup was created with errors",
|
||||
"description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the core and supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
"description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the Core and Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
},
|
||||
"automatic_backup_failed_folders": {
|
||||
"title": "Not all folders could be included in automatic backup",
|
||||
"description": "Folders {failed_folders} could not be included in automatic backup. Please check the supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
"description": "Folders {failed_folders} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.13.1"]
|
||||
"requirements": ["bthome-ble==3.14.2"]
|
||||
}
|
||||
|
@@ -25,6 +25,7 @@ from homeassistant.const import (
|
||||
DEGREE,
|
||||
LIGHT_LUX,
|
||||
PERCENTAGE,
|
||||
REVOLUTIONS_PER_MINUTE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfConductivity,
|
||||
@@ -269,6 +270,15 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=DEGREE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Rotational speed (rpm)
|
||||
(
|
||||
BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED,
|
||||
Units.REVOLUTIONS_PER_MINUTE,
|
||||
): SensorEntityDescription(
|
||||
key=f"{BTHomeExtendedSensorDeviceClass.ROTATIONAL_SPEED}_{Units.REVOLUTIONS_PER_MINUTE}",
|
||||
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Signal Strength (RSSI) (dB)
|
||||
(
|
||||
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,
|
||||
|
@@ -3,10 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from homeassistant.components.media_player import BrowseError, MediaClass
|
||||
from homeassistant.components.media_source import (
|
||||
@@ -21,7 +17,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import Camera, Image, _async_stream_endpoint_url, async_get_image
|
||||
from . import Camera, _async_stream_endpoint_url
|
||||
from .const import DATA_COMPONENT, DOMAIN, StreamType
|
||||
|
||||
|
||||
@@ -88,30 +84,6 @@ class CameraMediaSource(MediaSource):
|
||||
|
||||
return PlayMedia(url, FORMAT_CONTENT_TYPE[HLS_PROVIDER])
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_resolve_with_path(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve to playable item with path."""
|
||||
media = await self.async_resolve_media(item)
|
||||
entity_id = item.identifier
|
||||
image = await async_get_image(self.hass, entity_id)
|
||||
media.path = await self.hass.async_add_executor_job(
|
||||
self._save_camera_snapshot, image
|
||||
)
|
||||
|
||||
yield media
|
||||
|
||||
await self.hass.async_add_executor_job(media.path.unlink)
|
||||
|
||||
def _save_camera_snapshot(self, image: Image) -> Path:
|
||||
"""Save camera snapshot to temp file."""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
suffix=mimetypes.guess_extension(image.content_type, False),
|
||||
delete=False,
|
||||
) as temp_file:
|
||||
temp_file.write(image.content)
|
||||
return Path(temp_file.name)
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
item: MediaSourceItem,
|
||||
|
@@ -37,6 +37,10 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.loader import (
|
||||
async_get_custom_components,
|
||||
async_get_loaded_integration,
|
||||
)
|
||||
from homeassistant.util.location import async_detect_location_info
|
||||
|
||||
from .alexa_config import entity_supported as entity_supported_by_alexa
|
||||
@@ -431,6 +435,79 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
url = "/api/cloud/support_package"
|
||||
name = "api:cloud:support_package"
|
||||
|
||||
async def _get_integration_info(self, hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Collect information about active and custom integrations."""
|
||||
# Get loaded components from hass.config.components
|
||||
loaded_components = hass.config.components.copy()
|
||||
|
||||
# Get custom integrations
|
||||
custom_domains = set()
|
||||
with suppress(Exception):
|
||||
custom_domains = set(await async_get_custom_components(hass))
|
||||
|
||||
# Separate built-in and custom integrations
|
||||
builtin_integrations = []
|
||||
custom_integrations = []
|
||||
|
||||
for domain in sorted(loaded_components):
|
||||
try:
|
||||
integration = async_get_loaded_integration(hass, domain)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package
|
||||
# generation. If we can't get integration info,
|
||||
# just add the domain
|
||||
if domain in custom_domains:
|
||||
custom_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": "Unknown",
|
||||
"version": "Unknown",
|
||||
"documentation": "Unknown",
|
||||
}
|
||||
)
|
||||
else:
|
||||
builtin_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": "Unknown",
|
||||
}
|
||||
)
|
||||
else:
|
||||
if domain in custom_domains:
|
||||
# This is a custom integration
|
||||
# include version and documentation link
|
||||
version = (
|
||||
str(integration.version) if integration.version else "Unknown"
|
||||
)
|
||||
if not (documentation := integration.documentation):
|
||||
documentation = "Unknown"
|
||||
|
||||
custom_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": integration.name,
|
||||
"version": version,
|
||||
"documentation": documentation,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# This is a built-in integration.
|
||||
# No version needed, as it is always the same as the
|
||||
# Home Assistant version
|
||||
builtin_integrations.append(
|
||||
{
|
||||
"domain": domain,
|
||||
"name": integration.name,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"builtin_count": len(builtin_integrations),
|
||||
"builtin_integrations": builtin_integrations,
|
||||
"custom_count": len(custom_integrations),
|
||||
"custom_integrations": custom_integrations,
|
||||
}
|
||||
|
||||
async def _generate_markdown(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -453,6 +530,38 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
markdown = "## System Information\n\n"
|
||||
markdown += get_domain_table_markdown(hass_info)
|
||||
|
||||
# Add integration information
|
||||
try:
|
||||
integration_info = await self._get_integration_info(hass)
|
||||
except Exception: # noqa: BLE001
|
||||
# Broad exception catch for robustness in support package generation
|
||||
# If there's any error getting integration info, just note it
|
||||
markdown += "## Active integrations\n\n"
|
||||
markdown += "Unable to collect integration information\n\n"
|
||||
else:
|
||||
markdown += "## Active Integrations\n\n"
|
||||
markdown += f"Built-in integrations: {integration_info['builtin_count']}\n"
|
||||
markdown += f"Custom integrations: {integration_info['custom_count']}\n\n"
|
||||
|
||||
# Built-in integrations
|
||||
if integration_info["builtin_integrations"]:
|
||||
markdown += "<details><summary>Built-in integrations</summary>\n\n"
|
||||
markdown += "Domain | Name\n"
|
||||
markdown += "--- | ---\n"
|
||||
for integration in integration_info["builtin_integrations"]:
|
||||
markdown += f"{integration['domain']} | {integration['name']}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
# Custom integrations
|
||||
if integration_info["custom_integrations"]:
|
||||
markdown += "<details><summary>Custom integrations</summary>\n\n"
|
||||
markdown += "Domain | Name | Version | Documentation\n"
|
||||
markdown += "--- | --- | --- | ---\n"
|
||||
for integration in integration_info["custom_integrations"]:
|
||||
doc_url = integration.get("documentation") or "N/A"
|
||||
markdown += f"{integration['domain']} | {integration['name']} | {integration['version']} | {doc_url}\n"
|
||||
markdown += "\n</details>\n\n"
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import timedelta
|
||||
from typing import TypeVar
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from aiocomelit.api import (
|
||||
AlarmDataObject,
|
||||
@@ -13,7 +13,16 @@ from aiocomelit.api import (
|
||||
ComelitVedoAreaObject,
|
||||
ComelitVedoZoneObject,
|
||||
)
|
||||
from aiocomelit.const import BRIDGE, VEDO
|
||||
from aiocomelit.const import (
|
||||
BRIDGE,
|
||||
CLIMATE,
|
||||
COVER,
|
||||
IRRIGATION,
|
||||
LIGHT,
|
||||
OTHER,
|
||||
SCENARIO,
|
||||
VEDO,
|
||||
)
|
||||
from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
|
||||
from aiohttp import ClientSession
|
||||
|
||||
@@ -111,6 +120,32 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
|
||||
async def _async_update_system_data(self) -> T:
|
||||
"""Class method for updating data."""
|
||||
|
||||
async def _async_remove_stale_devices(
|
||||
self,
|
||||
previous_list: dict[int, Any],
|
||||
current_list: dict[int, Any],
|
||||
dev_type: str,
|
||||
) -> None:
|
||||
"""Remove stale devices."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
|
||||
for i in previous_list:
|
||||
if i not in current_list:
|
||||
_LOGGER.debug(
|
||||
"Detected change in %s devices: index %s removed",
|
||||
dev_type,
|
||||
i,
|
||||
)
|
||||
identifier = f"{self.config_entry.entry_id}-{dev_type}-{i}"
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, identifier)}
|
||||
)
|
||||
if device:
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
|
||||
class ComelitSerialBridge(
|
||||
ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
|
||||
@@ -137,7 +172,15 @@ class ComelitSerialBridge(
|
||||
self,
|
||||
) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
|
||||
"""Specific method for updating data."""
|
||||
return await self.api.get_all_devices()
|
||||
data = await self.api.get_all_devices()
|
||||
|
||||
if self.data:
|
||||
for dev_type in (CLIMATE, COVER, LIGHT, IRRIGATION, OTHER, SCENARIO):
|
||||
await self._async_remove_stale_devices(
|
||||
self.data[dev_type], data[dev_type], dev_type
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
|
||||
@@ -163,4 +206,14 @@ class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
|
||||
self,
|
||||
) -> AlarmDataObject:
|
||||
"""Specific method for updating data."""
|
||||
return await self.api.get_all_areas_and_zones()
|
||||
data = await self.api.get_all_areas_and_zones()
|
||||
|
||||
if self.data:
|
||||
for obj_type in ("alarm_areas", "alarm_zones"):
|
||||
await self._async_remove_stale_devices(
|
||||
self.data[obj_type],
|
||||
data[obj_type],
|
||||
"area" if obj_type == "alarm_areas" else "zone",
|
||||
)
|
||||
|
||||
return data
|
||||
|
@@ -72,9 +72,7 @@ rules:
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: missing implementation
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
@@ -50,14 +50,13 @@ from .const import (
|
||||
ATTR_LANGUAGE,
|
||||
ATTR_TEXT,
|
||||
DATA_COMPONENT,
|
||||
DATA_DEFAULT_ENTITY,
|
||||
DOMAIN,
|
||||
HOME_ASSISTANT_AGENT,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .default_agent import DefaultAgent, async_setup_default_agent
|
||||
from .default_agent import async_setup_default_agent
|
||||
from .entity import ConversationEntity
|
||||
from .http import async_setup as async_setup_conversation_http
|
||||
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||
@@ -142,7 +141,7 @@ def async_unset_agent(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Set the agent to handle the conversations."""
|
||||
"""Unset the agent to handle the conversations."""
|
||||
get_agent_manager(hass).async_unset_agent(config_entry.entry_id)
|
||||
|
||||
|
||||
@@ -241,10 +240,10 @@ async def async_handle_sentence_triggers(
|
||||
|
||||
Returns None if no match occurred.
|
||||
"""
|
||||
default_agent = async_get_agent(hass)
|
||||
assert isinstance(default_agent, DefaultAgent)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
return await default_agent.async_handle_sentence_triggers(user_input)
|
||||
return await agent.async_handle_sentence_triggers(user_input)
|
||||
|
||||
|
||||
async def async_handle_intents(
|
||||
@@ -257,12 +256,10 @@ async def async_handle_intents(
|
||||
|
||||
Returns None if no match occurred.
|
||||
"""
|
||||
default_agent = async_get_agent(hass)
|
||||
assert isinstance(default_agent, DefaultAgent)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
return await default_agent.async_handle_intents(
|
||||
user_input, intent_filter=intent_filter
|
||||
)
|
||||
return await agent.async_handle_intents(user_input, intent_filter=intent_filter)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
@@ -298,9 +295,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def handle_reload(service: ServiceCall) -> None:
|
||||
"""Reload intents."""
|
||||
await hass.data[DATA_DEFAULT_ENTITY].async_reload(
|
||||
language=service.data.get(ATTR_LANGUAGE)
|
||||
)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
if agent is not None:
|
||||
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent, singleton
|
||||
|
||||
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT
|
||||
from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT
|
||||
from .entity import ConversationEntity
|
||||
from .models import (
|
||||
AbstractConversationAgent,
|
||||
@@ -28,6 +28,9 @@ from .trace import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .default_agent import DefaultAgent
|
||||
|
||||
|
||||
@singleton.singleton("conversation_agent")
|
||||
@callback
|
||||
@@ -49,8 +52,10 @@ def async_get_agent(
|
||||
hass: HomeAssistant, agent_id: str | None = None
|
||||
) -> AbstractConversationAgent | ConversationEntity | None:
|
||||
"""Get specified agent."""
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
if agent_id is None or agent_id == HOME_ASSISTANT_AGENT:
|
||||
return hass.data[DATA_DEFAULT_ENTITY]
|
||||
return manager.default_agent
|
||||
|
||||
if "." in agent_id:
|
||||
return hass.data[DATA_COMPONENT].get_entity(agent_id)
|
||||
@@ -134,6 +139,7 @@ class AgentManager:
|
||||
"""Initialize the conversation agents."""
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
|
||||
@callback
|
||||
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
|
||||
@@ -182,3 +188,7 @@ class AgentManager:
|
||||
def async_unset_agent(self, agent_id: str) -> None:
|
||||
"""Unset the agent."""
|
||||
self._agents.pop(agent_id, None)
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
self.default_agent = agent
|
||||
|
@@ -10,11 +10,9 @@ from homeassistant.util.hass_dict import HassKey
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
from .default_agent import DefaultAgent
|
||||
from .entity import ConversationEntity
|
||||
|
||||
DOMAIN = "conversation"
|
||||
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
HOME_ASSISTANT_AGENT = "conversation.home_assistant"
|
||||
|
||||
ATTR_TEXT = "text"
|
||||
@@ -26,7 +24,6 @@ SERVICE_PROCESS = "process"
|
||||
SERVICE_RELOAD = "reload"
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
|
||||
DATA_DEFAULT_ENTITY: HassKey[DefaultAgent] = HassKey(f"{DOMAIN}_default_entity")
|
||||
|
||||
|
||||
class ConversationEntityFeature(IntFlag):
|
||||
|
@@ -68,13 +68,9 @@ from homeassistant.helpers.event import async_track_state_added_domain
|
||||
from homeassistant.util import language as language_util
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .agent_manager import get_agent_manager
|
||||
from .chat_log import AssistantContent, ChatLog
|
||||
from .const import (
|
||||
DATA_DEFAULT_ENTITY,
|
||||
DEFAULT_EXPOSED_ATTRIBUTES,
|
||||
DOMAIN,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .const import DOMAIN, ConversationEntityFeature
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
@@ -83,6 +79,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
||||
|
||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
|
||||
REGEX_TYPE = type(re.compile(""))
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
@@ -209,9 +207,9 @@ async def async_setup_default_agent(
|
||||
config_intents: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set up entity registry listener for the default agent."""
|
||||
entity = DefaultAgent(hass, config_intents)
|
||||
await entity_component.async_add_entities([entity])
|
||||
hass.data[DATA_DEFAULT_ENTITY] = entity
|
||||
agent = DefaultAgent(hass, config_intents)
|
||||
await entity_component.async_add_entities([agent])
|
||||
await get_agent_manager(hass).async_setup_default_agent(agent)
|
||||
|
||||
@core.callback
|
||||
def async_entity_state_listener(
|
||||
@@ -846,7 +844,7 @@ class DefaultAgent(ConversationEntity):
|
||||
context = {"domain": state.domain}
|
||||
if state.attributes:
|
||||
# Include some attributes
|
||||
for attr in DEFAULT_EXPOSED_ATTRIBUTES:
|
||||
for attr in _DEFAULT_EXPOSED_ATTRIBUTES:
|
||||
if attr not in state.attributes:
|
||||
continue
|
||||
context[attr] = state.attributes[attr]
|
||||
|
@@ -25,7 +25,7 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
|
||||
from .const import DATA_COMPONENT
|
||||
from .default_agent import (
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
@@ -169,7 +169,8 @@ async def websocket_list_sentences(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""List custom registered sentences."""
|
||||
agent = hass.data[DATA_DEFAULT_ENTITY]
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
sentences = []
|
||||
for trigger_data in agent.trigger_sentences:
|
||||
@@ -191,7 +192,8 @@ async def websocket_hass_agent_debug(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""Return intents that would be matched by the default agent for a list of sentences."""
|
||||
agent = hass.data[DATA_DEFAULT_ENTITY]
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
# Return results for each sentence in the same order as the input.
|
||||
result_dicts: list[dict[str, Any] | None] = []
|
||||
|
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:forum-outline"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"process": {
|
||||
"service": "mdi:message-processing"
|
||||
|
@@ -4,7 +4,7 @@
|
||||
"codeowners": ["@home-assistant/core", "@synesthesiam", "@arturpragacz"],
|
||||
"dependencies": ["http", "intent"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"]
|
||||
}
|
||||
|
@@ -20,7 +20,8 @@ from homeassistant.helpers.script import ScriptRunResult
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import UNDEFINED, ConfigType
|
||||
|
||||
from .const import DATA_DEFAULT_ENTITY, DOMAIN
|
||||
from .agent_manager import get_agent_manager
|
||||
from .const import DOMAIN
|
||||
from .models import ConversationInput
|
||||
|
||||
|
||||
@@ -123,4 +124,6 @@ async def async_attach_trigger(
|
||||
# two trigger copies for who will provide a response.
|
||||
return None
|
||||
|
||||
return hass.data[DATA_DEFAULT_ENTITY].register_trigger(sentences, call_action)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
return agent.register_trigger(sentences, call_action)
|
||||
|
@@ -19,6 +19,7 @@
|
||||
"ssdp",
|
||||
"stream",
|
||||
"sun",
|
||||
"usage_prediction",
|
||||
"usb",
|
||||
"webhook",
|
||||
"zeroconf"
|
||||
|
@@ -43,3 +43,5 @@ class DelugeSensorType(enum.StrEnum):
|
||||
UPLOAD_SPEED_SENSOR = "upload_speed"
|
||||
PROTOCOL_TRAFFIC_UPLOAD_SPEED_SENSOR = "protocol_traffic_upload_speed"
|
||||
PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR = "protocol_traffic_download_speed"
|
||||
DOWNLOADING_COUNT_SENSOR = "downloading_count"
|
||||
SEEDING_COUNT_SENSOR = "seeding_count"
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
from ssl import SSLError
|
||||
from typing import Any
|
||||
@@ -14,11 +15,22 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import LOGGER, DelugeGetSessionStatusKeys
|
||||
from .const import LOGGER, DelugeGetSessionStatusKeys, DelugeSensorType
|
||||
|
||||
type DelugeConfigEntry = ConfigEntry[DelugeDataUpdateCoordinator]
|
||||
|
||||
|
||||
def count_states(data: dict[str, Any]) -> dict[str, int]:
|
||||
"""Count the states of the provided torrents."""
|
||||
|
||||
counts = Counter(torrent[b"state"].decode() for torrent in data.values())
|
||||
|
||||
return {
|
||||
DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value: counts.get("Downloading", 0),
|
||||
DelugeSensorType.SEEDING_COUNT_SENSOR.value: counts.get("Seeding", 0),
|
||||
}
|
||||
|
||||
|
||||
class DelugeDataUpdateCoordinator(
|
||||
DataUpdateCoordinator[dict[Platform, dict[str, Any]]]
|
||||
):
|
||||
@@ -39,19 +51,22 @@ class DelugeDataUpdateCoordinator(
|
||||
)
|
||||
self.api = api
|
||||
|
||||
async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
|
||||
"""Get the latest data from Deluge and updates the state."""
|
||||
def _get_deluge_data(self):
|
||||
"""Get the latest data from Deluge."""
|
||||
|
||||
data = {}
|
||||
try:
|
||||
_data = await self.hass.async_add_executor_job(
|
||||
self.api.call,
|
||||
data["session_status"] = self.api.call(
|
||||
"core.get_session_status",
|
||||
[iter_member.value for iter_member in list(DelugeGetSessionStatusKeys)],
|
||||
)
|
||||
data[Platform.SENSOR] = {k.decode(): v for k, v in _data.items()}
|
||||
data[Platform.SWITCH] = await self.hass.async_add_executor_job(
|
||||
self.api.call, "core.get_torrents_status", {}, ["paused"]
|
||||
data["torrents_status_state"] = self.api.call(
|
||||
"core.get_torrents_status", {}, ["state"]
|
||||
)
|
||||
data["torrents_status_paused"] = self.api.call(
|
||||
"core.get_torrents_status", {}, ["paused"]
|
||||
)
|
||||
|
||||
except (
|
||||
ConnectionRefusedError,
|
||||
TimeoutError,
|
||||
@@ -66,4 +81,18 @@ class DelugeDataUpdateCoordinator(
|
||||
) from ex
|
||||
LOGGER.error("Unknown error connecting to Deluge: %s", ex)
|
||||
raise
|
||||
|
||||
return data
|
||||
|
||||
async def _async_update_data(self) -> dict[Platform, dict[str, Any]]:
|
||||
"""Get the latest data from Deluge and updates the state."""
|
||||
|
||||
deluge_data = await self.hass.async_add_executor_job(self._get_deluge_data)
|
||||
|
||||
data = {}
|
||||
data[Platform.SENSOR] = {
|
||||
k.decode(): v for k, v in deluge_data["session_status"].items()
|
||||
}
|
||||
data[Platform.SENSOR].update(count_states(deluge_data["torrents_status_state"]))
|
||||
data[Platform.SWITCH] = deluge_data["torrents_status_paused"]
|
||||
return data
|
||||
|
12
homeassistant/components/deluge/icons.json
Normal file
12
homeassistant/components/deluge/icons.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"downloading_count": {
|
||||
"default": "mdi:download"
|
||||
},
|
||||
"seeding_count": {
|
||||
"default": "mdi:upload"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -110,6 +110,18 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = (
|
||||
data, DelugeSensorType.PROTOCOL_TRAFFIC_DOWNLOAD_SPEED_SENSOR.value
|
||||
),
|
||||
),
|
||||
DelugeSensorEntityDescription(
|
||||
key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
|
||||
translation_key=DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value=lambda data: data[DelugeSensorType.DOWNLOADING_COUNT_SENSOR.value],
|
||||
),
|
||||
DelugeSensorEntityDescription(
|
||||
key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
|
||||
translation_key=DelugeSensorType.SEEDING_COUNT_SENSOR.value,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
value=lambda data: data[DelugeSensorType.SEEDING_COUNT_SENSOR.value],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -36,6 +36,10 @@
|
||||
"idle": "[%key:common::state::idle%]"
|
||||
}
|
||||
},
|
||||
"downloading_count": {
|
||||
"name": "Downloading count",
|
||||
"unit_of_measurement": "torrents"
|
||||
},
|
||||
"download_speed": {
|
||||
"name": "Download speed"
|
||||
},
|
||||
@@ -45,6 +49,10 @@
|
||||
"protocol_traffic_upload_speed": {
|
||||
"name": "Protocol traffic upload speed"
|
||||
},
|
||||
"seeding_count": {
|
||||
"name": "Seeding count",
|
||||
"unit_of_measurement": "[%key:component::deluge::entity::sensor::downloading_count::unit_of_measurement%]"
|
||||
},
|
||||
"upload_speed": {
|
||||
"name": "Upload speed"
|
||||
}
|
||||
|
23
homeassistant/components/derivative/diagnostics.py
Normal file
23
homeassistant/components/derivative/diagnostics.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Diagnostics support for derivative."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
registry = er.async_get(hass)
|
||||
entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
|
||||
|
||||
return {
|
||||
"config_entry": config_entry.as_dict(),
|
||||
"entity": [entity.extended_dict for entity in entities],
|
||||
}
|
@@ -227,15 +227,28 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
weight = calculate_weight(start, end, current_time)
|
||||
derivative = derivative + (value * Decimal(weight))
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Calculated new derivative as %f from %d segments",
|
||||
self.entity_id,
|
||||
derivative,
|
||||
len(self._state_list),
|
||||
)
|
||||
|
||||
return derivative
|
||||
|
||||
def _prune_state_list(self, current_time: datetime) -> None:
|
||||
# filter out all derivatives older than `time_window` from our window list
|
||||
old_len = len(self._state_list)
|
||||
self._state_list = [
|
||||
(time_start, time_end, state)
|
||||
for time_start, time_end, state in self._state_list
|
||||
if (current_time - time_end).total_seconds() < self._time_window
|
||||
]
|
||||
_LOGGER.debug(
|
||||
"%s: Pruned %d elements from state list",
|
||||
self.entity_id,
|
||||
old_len - len(self._state_list),
|
||||
)
|
||||
|
||||
def _handle_invalid_source_state(self, state: State | None) -> bool:
|
||||
# Check the source state for unknown/unavailable condition. If unusable, write unknown/unavailable state and return false.
|
||||
@@ -292,6 +305,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
) -> None:
|
||||
"""Calculate derivative based on time and reschedule."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Recalculating derivative due to max_sub_interval time elapsed",
|
||||
self.entity_id,
|
||||
)
|
||||
self._prune_state_list(now)
|
||||
derivative = self._calc_derivative_from_state_list(now)
|
||||
self._write_native_value(derivative)
|
||||
@@ -300,6 +317,11 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
if derivative != 0:
|
||||
schedule_max_sub_interval_exceeded(source_state)
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Scheduling max_sub_interval_callback in %s",
|
||||
self.entity_id,
|
||||
self._max_sub_interval,
|
||||
)
|
||||
self._cancel_max_sub_interval_exceeded_callback = async_call_later(
|
||||
self.hass,
|
||||
self._max_sub_interval,
|
||||
@@ -309,6 +331,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
@callback
|
||||
def on_state_reported(event: Event[EventStateReportedData]) -> None:
|
||||
"""Handle constant sensor state."""
|
||||
_LOGGER.debug(
|
||||
"%s: New state reported event: %s", self.entity_id, event.data
|
||||
)
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
if not self._handle_invalid_source_state(new_state):
|
||||
@@ -330,6 +355,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
@callback
|
||||
def on_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle changed sensor state."""
|
||||
_LOGGER.debug("%s: New state changed event: %s", self.entity_id, event.data)
|
||||
self._cancel_max_sub_interval_exceeded_callback()
|
||||
new_state = event.data["new_state"]
|
||||
if not self._handle_invalid_source_state(new_state):
|
||||
@@ -382,15 +408,32 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
/ Decimal(self._unit_prefix)
|
||||
* Decimal(self._unit_time)
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"%s: Calculated new derivative segment as %f / %f / %f * %f = %f",
|
||||
self.entity_id,
|
||||
delta_value,
|
||||
elapsed_time,
|
||||
self._unit_prefix,
|
||||
self._unit_time,
|
||||
new_derivative,
|
||||
)
|
||||
|
||||
except ValueError as err:
|
||||
_LOGGER.warning("While calculating derivative: %s", err)
|
||||
_LOGGER.warning(
|
||||
"%s: While calculating derivative: %s", self.entity_id, err
|
||||
)
|
||||
except DecimalException as err:
|
||||
_LOGGER.warning(
|
||||
"Invalid state (%s > %s): %s", old_value, new_state.state, err
|
||||
"%s: Invalid state (%s > %s): %s",
|
||||
self.entity_id,
|
||||
old_value,
|
||||
new_state.state,
|
||||
err,
|
||||
)
|
||||
except AssertionError as err:
|
||||
_LOGGER.error("Could not calculate derivative: %s", err)
|
||||
_LOGGER.error(
|
||||
"%s: Could not calculate derivative: %s", self.entity_id, err
|
||||
)
|
||||
|
||||
# For total inreasing sensors, the value is expected to continuously increase.
|
||||
# A negative derivative for a total increasing sensor likely indicates the
|
||||
@@ -400,6 +443,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
== SensorStateClass.TOTAL_INCREASING
|
||||
and new_derivative < 0
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"%s: Dropping sample as source total_increasing sensor decreased",
|
||||
self.entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
# add latest derivative to the window list
|
||||
|
@@ -152,24 +152,28 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
EcoWittSensorTypes.RAIN_COUNT_INCHES: SensorEntityDescription(
|
||||
key="RAIN_COUNT_INCHES",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
EcoWittSensorTypes.RAIN_RATE_MM: SensorEntityDescription(
|
||||
key="RAIN_RATE_MM",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
EcoWittSensorTypes.RAIN_RATE_INCHES: SensorEntityDescription(
|
||||
key="RAIN_RATE_INCHES",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription(
|
||||
key="LIGHTNING_DISTANCE_KM",
|
||||
@@ -230,6 +234,17 @@ ECOWITT_SENSORS_MAPPING: Final = {
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.DISTANCE_MM: SensorEntityDescription(
|
||||
key="DISTANCE_MM",
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.HEAT_COUNT: SensorEntityDescription(
|
||||
key="HEAT_COUNT",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
EcoWittSensorTypes.PM1: SensorEntityDescription(
|
||||
key="PM1",
|
||||
device_class=SensorDeviceClass.PM1,
|
||||
|
@@ -120,6 +120,14 @@ def _make_url_from_data(data: dict[str, str]) -> str:
|
||||
return f"{protocol}{address}"
|
||||
|
||||
|
||||
def _get_protocol_from_url(url: str) -> str:
|
||||
"""Get protocol from URL. Returns the configured protocol from URL or the default secure protocol."""
|
||||
return next(
|
||||
(k for k, v in PROTOCOL_MAP.items() if url.startswith(v)),
|
||||
DEFAULT_SECURE_PROTOCOL,
|
||||
)
|
||||
|
||||
|
||||
def _placeholders_from_device(device: ElkSystem) -> dict[str, str]:
|
||||
return {
|
||||
"mac_address": _short_mac(device.mac_address),
|
||||
@@ -205,6 +213,78 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
return await self.async_step_discovered_connection()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
existing_data = reconfigure_entry.data
|
||||
|
||||
if user_input is not None:
|
||||
validate_input_data = dict(user_input)
|
||||
validate_input_data[CONF_PREFIX] = existing_data.get(CONF_PREFIX, "")
|
||||
|
||||
try:
|
||||
info = await validate_input(
|
||||
validate_input_data, reconfigure_entry.unique_id
|
||||
)
|
||||
except TimeoutError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors[CONF_PASSWORD] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during reconfiguration")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
# Discover the device at the provided address to obtain its MAC (unique_id)
|
||||
device = await async_discover_device(
|
||||
self.hass, validate_input_data[CONF_ADDRESS]
|
||||
)
|
||||
if device is not None and device.mac_address:
|
||||
await self.async_set_unique_id(dr.format_mac(device.mac_address))
|
||||
self._abort_if_unique_id_mismatch() # aborts if user tried to switch devices
|
||||
else:
|
||||
# If we cannot confirm identity, keep existing behavior (don't block reconfigure)
|
||||
await self.async_set_unique_id(reconfigure_entry.unique_id)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
**reconfigure_entry.data,
|
||||
CONF_HOST: info[CONF_HOST],
|
||||
CONF_USERNAME: validate_input_data[CONF_USERNAME],
|
||||
CONF_PASSWORD: validate_input_data[CONF_PASSWORD],
|
||||
CONF_PREFIX: info[CONF_PREFIX],
|
||||
},
|
||||
reason="reconfigure_successful",
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
default=existing_data.get(CONF_USERNAME, ""),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default="",
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_ADDRESS,
|
||||
default=hostname_from_url(existing_data[CONF_HOST]),
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_PROTOCOL,
|
||||
default=_get_protocol_from_url(existing_data[CONF_HOST]),
|
||||
): vol.In(ALL_PROTOCOLS),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -249,12 +329,14 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
info = await validate_input(user_input, self.unique_id)
|
||||
except TimeoutError:
|
||||
except TimeoutError as ex:
|
||||
_LOGGER.debug("Connection timed out: %s", ex)
|
||||
return {"base": "cannot_connect"}, None
|
||||
except InvalidAuth:
|
||||
except InvalidAuth as ex:
|
||||
_LOGGER.debug("Invalid auth for %s: %s", user_input.get(CONF_HOST), ex)
|
||||
return {CONF_PASSWORD: "invalid_auth"}, None
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
_LOGGER.exception("Unexpected error validating input")
|
||||
return {"base": "unknown"}, None
|
||||
|
||||
if importing:
|
||||
|
@@ -17,8 +17,8 @@
|
||||
"address": "The IP address or domain or serial port if connecting via serial.",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"prefix": "A unique prefix (leave blank if you only have one ElkM1).",
|
||||
"temperature_unit": "The temperature unit ElkM1 uses."
|
||||
"prefix": "A unique prefix (leave blank if you only have one Elk-M1).",
|
||||
"temperature_unit": "The temperature unit Elk-M1 uses."
|
||||
}
|
||||
},
|
||||
"discovered_connection": {
|
||||
@@ -30,6 +30,16 @@
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"temperature_unit": "[%key:component::elkm1::config::step::manual_connection::data::temperature_unit%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Reconfigure Elk-M1 Control",
|
||||
"description": "[%key:component::elkm1::config::step::manual_connection::description%]",
|
||||
"data": {
|
||||
"protocol": "[%key:component::elkm1::config::step::manual_connection::data::protocol%]",
|
||||
"address": "[%key:component::elkm1::config::step::manual_connection::data::address%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -42,8 +52,10 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"already_configured": "An ElkM1 with this prefix is already configured",
|
||||
"address_already_configured": "An ElkM1 with this address is already configured"
|
||||
"already_configured": "An Elk-M1 with this prefix is already configured",
|
||||
"address_already_configured": "An Elk-M1 with this address is already configured",
|
||||
"reconfigure_successful": "Successfully reconfigured Elk-M1 integration",
|
||||
"unique_id_mismatch": "Reconfigure should be used for the same device not a new one"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -69,7 +81,7 @@
|
||||
},
|
||||
"alarm_arm_home_instant": {
|
||||
"name": "Alarm arm home instant",
|
||||
"description": "Arms the ElkM1 in home instant mode.",
|
||||
"description": "Arms the Elk-M1 in home instant mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"name": "Code",
|
||||
@@ -79,7 +91,7 @@
|
||||
},
|
||||
"alarm_arm_night_instant": {
|
||||
"name": "Alarm arm night instant",
|
||||
"description": "Arms the ElkM1 in night instant mode.",
|
||||
"description": "Arms the Elk-M1 in night instant mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"name": "Code",
|
||||
@@ -89,7 +101,7 @@
|
||||
},
|
||||
"alarm_arm_vacation": {
|
||||
"name": "Alarm arm vacation",
|
||||
"description": "Arms the ElkM1 in vacation mode.",
|
||||
"description": "Arms the Elk-M1 in vacation mode.",
|
||||
"fields": {
|
||||
"code": {
|
||||
"name": "Code",
|
||||
@@ -99,7 +111,7 @@
|
||||
},
|
||||
"alarm_display_message": {
|
||||
"name": "Alarm display message",
|
||||
"description": "Displays a message on all of the ElkM1 keypads for an area.",
|
||||
"description": "Displays a message on all of the Elk-M1 keypads for an area.",
|
||||
"fields": {
|
||||
"clear": {
|
||||
"name": "Clear",
|
||||
@@ -135,7 +147,7 @@
|
||||
},
|
||||
"speak_phrase": {
|
||||
"name": "Speak phrase",
|
||||
"description": "Speaks a phrase. See list of phrases in ElkM1 ASCII Protocol documentation.",
|
||||
"description": "Speaks a phrase. See list of phrases in Elk-M1 ASCII Protocol documentation.",
|
||||
"fields": {
|
||||
"number": {
|
||||
"name": "Phrase number",
|
||||
@@ -149,7 +161,7 @@
|
||||
},
|
||||
"speak_word": {
|
||||
"name": "Speak word",
|
||||
"description": "Speaks a word. See list of words in ElkM1 ASCII Protocol documentation.",
|
||||
"description": "Speaks a word. See list of words in Elk-M1 ASCII Protocol documentation.",
|
||||
"fields": {
|
||||
"number": {
|
||||
"name": "Word number",
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/emoncms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyemoncms==0.1.2"]
|
||||
"requirements": ["pyemoncms==0.1.3"]
|
||||
}
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/emoncms_history",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyemoncms==0.1.2"]
|
||||
"requirements": ["pyemoncms==0.1.3"]
|
||||
}
|
||||
|
@@ -52,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool:
|
||||
f"[{eq3_config.mac_address}] Device could not be found"
|
||||
)
|
||||
|
||||
thermostat = Thermostat(mac_address=device) # type: ignore[arg-type]
|
||||
thermostat = Thermostat(device)
|
||||
|
||||
entry.runtime_data = Eq3ConfigEntryData(
|
||||
eq3_config=eq3_config, thermostat=thermostat
|
||||
|
@@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.3.0"]
|
||||
"requirements": ["eq3btsmart==2.3.0"]
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==40.1.0",
|
||||
"aioesphomeapi==41.1.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.3.0"
|
||||
],
|
||||
|
@@ -12,7 +12,7 @@
|
||||
"mqtt_missing_mac": "Missing MAC address in MQTT properties.",
|
||||
"mqtt_missing_api": "Missing API port in MQTT properties.",
|
||||
"mqtt_missing_ip": "Missing IP address in MQTT properties.",
|
||||
"mqtt_missing_payload": "Missing MQTT Payload.",
|
||||
"mqtt_missing_payload": "Missing MQTT payload.",
|
||||
"name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).",
|
||||
@@ -91,7 +91,7 @@
|
||||
"subscribe_logs": "Subscribe to logs from the device."
|
||||
},
|
||||
"data_description": {
|
||||
"allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions, such as calling services or sending events. Only enable this if you trust the device.",
|
||||
"allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.",
|
||||
"subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,7 @@
|
||||
"description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme."
|
||||
},
|
||||
"api_password_deprecated": {
|
||||
"title": "API Password deprecated on {name}",
|
||||
"title": "API password deprecated on {name}",
|
||||
"description": "The API password for ESPHome is deprecated and the use of an API encryption key is recommended instead.\n\nRemove the API password and add an encryption key to your ESPHome device to resolve this issue."
|
||||
},
|
||||
"service_calls_not_allowed": {
|
||||
@@ -193,10 +193,10 @@
|
||||
"message": "Error communicating with the device {device_name}: {error}"
|
||||
},
|
||||
"error_compiling": {
|
||||
"message": "Error compiling {configuration}; Try again in ESPHome dashboard for more information."
|
||||
"message": "Error compiling {configuration}. Try again in ESPHome dashboard for more information."
|
||||
},
|
||||
"error_uploading": {
|
||||
"message": "Error during OTA (Over-The-Air) of {configuration}; Try again in ESPHome dashboard for more information."
|
||||
"message": "Error during OTA (Over-The-Air) update of {configuration}. Try again in ESPHome dashboard for more information."
|
||||
},
|
||||
"ota_in_progress": {
|
||||
"message": "An OTA (Over-The-Air) update is already in progress for {configuration}."
|
||||
|
@@ -14,13 +14,7 @@ from homeassistant.components.climate import (
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.components.modbus import (
|
||||
CALL_TYPE_REGISTER_HOLDING,
|
||||
CALL_TYPE_REGISTER_INPUT,
|
||||
DEFAULT_HUB,
|
||||
ModbusHub,
|
||||
get_hub,
|
||||
)
|
||||
from homeassistant.components.modbus import ModbusHub, get_hub
|
||||
from homeassistant.const import (
|
||||
ATTR_TEMPERATURE,
|
||||
CONF_NAME,
|
||||
@@ -33,7 +27,13 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
# These constants are not offered by modbus, because modbus do not have
|
||||
# an official API.
|
||||
CALL_TYPE_REGISTER_HOLDING = "holding"
|
||||
CALL_TYPE_REGISTER_INPUT = "input"
|
||||
CALL_TYPE_WRITE_REGISTER = "write_register"
|
||||
DEFAULT_HUB = "modbus_hub"
|
||||
|
||||
CONF_HUB = "hub"
|
||||
|
||||
PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend(
|
||||
|
39
homeassistant/components/geocaching/entity.py
Normal file
39
homeassistant/components/geocaching/entity.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Sensor entities for Geocaching."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from geocachingapi.models import GeocachingCache
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GeocachingDataUpdateCoordinator
|
||||
|
||||
|
||||
# Base class for all platforms
|
||||
class GeocachingBaseEntity(CoordinatorEntity[GeocachingDataUpdateCoordinator]):
|
||||
"""Base class for Geocaching sensors."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
|
||||
# Base class for cache entities
|
||||
class GeocachingCacheEntity(GeocachingBaseEntity):
|
||||
"""Base class for Geocaching cache entities."""
|
||||
|
||||
def __init__(
|
||||
self, coordinator: GeocachingDataUpdateCoordinator, cache: GeocachingCache
|
||||
) -> None:
|
||||
"""Initialize the Geocaching cache entity."""
|
||||
super().__init__(coordinator)
|
||||
self.cache = cache
|
||||
|
||||
# A device can have multiple entities, and for a cache which requires multiple entities we want to group them together.
|
||||
# Therefore, we create a device for each cache, which holds all related entities.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=f"Geocache {cache.name}",
|
||||
identifiers={(DOMAIN, cast(str, cache.reference_code))},
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
manufacturer=cache.owner.username,
|
||||
)
|
@@ -15,6 +15,24 @@
|
||||
},
|
||||
"awarded_favorite_points": {
|
||||
"default": "mdi:heart"
|
||||
},
|
||||
"cache_name": {
|
||||
"default": "mdi:label"
|
||||
},
|
||||
"cache_owner": {
|
||||
"default": "mdi:account"
|
||||
},
|
||||
"cache_found_date": {
|
||||
"default": "mdi:calendar-search"
|
||||
},
|
||||
"cache_found": {
|
||||
"default": "mdi:package-variant-closed-check"
|
||||
},
|
||||
"cache_favorite_points": {
|
||||
"default": "mdi:star-check"
|
||||
},
|
||||
"cache_hidden_date": {
|
||||
"default": "mdi:calendar-badge"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,18 +4,25 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
from typing import cast
|
||||
|
||||
from geocachingapi.models import GeocachingStatus
|
||||
from geocachingapi.models import GeocachingCache, GeocachingStatus
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import GeocachingConfigEntry, GeocachingDataUpdateCoordinator
|
||||
from .entity import GeocachingBaseEntity, GeocachingCacheEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -25,43 +32,63 @@ class GeocachingSensorEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[GeocachingStatus], str | int | None]
|
||||
|
||||
|
||||
SENSORS: tuple[GeocachingSensorEntityDescription, ...] = (
|
||||
PROFILE_SENSORS: tuple[GeocachingSensorEntityDescription, ...] = (
|
||||
GeocachingSensorEntityDescription(
|
||||
key="find_count",
|
||||
translation_key="find_count",
|
||||
native_unit_of_measurement="caches",
|
||||
value_fn=lambda status: status.user.find_count,
|
||||
),
|
||||
GeocachingSensorEntityDescription(
|
||||
key="hide_count",
|
||||
translation_key="hide_count",
|
||||
native_unit_of_measurement="caches",
|
||||
entity_registry_visible_default=False,
|
||||
value_fn=lambda status: status.user.hide_count,
|
||||
),
|
||||
GeocachingSensorEntityDescription(
|
||||
key="favorite_points",
|
||||
translation_key="favorite_points",
|
||||
native_unit_of_measurement="points",
|
||||
entity_registry_visible_default=False,
|
||||
value_fn=lambda status: status.user.favorite_points,
|
||||
),
|
||||
GeocachingSensorEntityDescription(
|
||||
key="souvenir_count",
|
||||
translation_key="souvenir_count",
|
||||
native_unit_of_measurement="souvenirs",
|
||||
value_fn=lambda status: status.user.souvenir_count,
|
||||
),
|
||||
GeocachingSensorEntityDescription(
|
||||
key="awarded_favorite_points",
|
||||
translation_key="awarded_favorite_points",
|
||||
native_unit_of_measurement="points",
|
||||
entity_registry_visible_default=False,
|
||||
value_fn=lambda status: status.user.awarded_favorite_points,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class GeocachingCacheSensorDescription(SensorEntityDescription):
|
||||
"""Define Sensor entity description class."""
|
||||
|
||||
value_fn: Callable[[GeocachingCache], StateType | datetime.date]
|
||||
|
||||
|
||||
CACHE_SENSORS: tuple[GeocachingCacheSensorDescription, ...] = (
|
||||
GeocachingCacheSensorDescription(
|
||||
key="found_date",
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
value_fn=lambda cache: cache.found_date_time,
|
||||
),
|
||||
GeocachingCacheSensorDescription(
|
||||
key="favorite_points",
|
||||
value_fn=lambda cache: cache.favorite_points,
|
||||
),
|
||||
GeocachingCacheSensorDescription(
|
||||
key="hidden_date",
|
||||
device_class=SensorDeviceClass.DATE,
|
||||
value_fn=lambda cache: cache.hidden_date,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: GeocachingConfigEntry,
|
||||
@@ -69,14 +96,68 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up a Geocaching sensor entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
GeocachingSensor(coordinator, description) for description in SENSORS
|
||||
|
||||
entities: list[Entity] = []
|
||||
|
||||
entities.extend(
|
||||
GeocachingProfileSensor(coordinator, description)
|
||||
for description in PROFILE_SENSORS
|
||||
)
|
||||
|
||||
status = coordinator.data
|
||||
|
||||
class GeocachingSensor(
|
||||
CoordinatorEntity[GeocachingDataUpdateCoordinator], SensorEntity
|
||||
):
|
||||
# Add entities for tracked caches
|
||||
entities.extend(
|
||||
GeoEntityCacheSensorEntity(coordinator, cache, description)
|
||||
for cache in status.tracked_caches
|
||||
for description in CACHE_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
# Base class for a cache entity.
|
||||
# Sets the device, ID and translation settings to correctly group the entity to the correct cache device and give it the correct name.
|
||||
class GeoEntityBaseCache(GeocachingCacheEntity, SensorEntity):
|
||||
"""Base class for cache entities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GeocachingDataUpdateCoordinator,
|
||||
cache: GeocachingCache,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Initialize the Geocaching sensor."""
|
||||
super().__init__(coordinator, cache)
|
||||
|
||||
self._attr_unique_id = f"{cache.reference_code}_{key}"
|
||||
|
||||
# The translation key determines the name of the entity as this is the lookup for the `strings.json` file.
|
||||
self._attr_translation_key = f"cache_{key}"
|
||||
|
||||
|
||||
class GeoEntityCacheSensorEntity(GeoEntityBaseCache, SensorEntity):
|
||||
"""Representation of a cache sensor."""
|
||||
|
||||
entity_description: GeocachingCacheSensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GeocachingDataUpdateCoordinator,
|
||||
cache: GeocachingCache,
|
||||
description: GeocachingCacheSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the Geocaching sensor."""
|
||||
super().__init__(coordinator, cache, description.key)
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime.date:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.cache)
|
||||
|
||||
|
||||
class GeocachingProfileSensor(GeocachingBaseEntity, SensorEntity):
|
||||
"""Representation of a Sensor."""
|
||||
|
||||
entity_description: GeocachingSensorEntityDescription
|
||||
|
@@ -33,11 +33,36 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"find_count": { "name": "Total finds" },
|
||||
"hide_count": { "name": "Total hides" },
|
||||
"favorite_points": { "name": "Favorite points" },
|
||||
"souvenir_count": { "name": "Total souvenirs" },
|
||||
"awarded_favorite_points": { "name": "Awarded favorite points" }
|
||||
"find_count": {
|
||||
"name": "Total finds",
|
||||
"unit_of_measurement": "caches"
|
||||
},
|
||||
"hide_count": {
|
||||
"name": "Total hides",
|
||||
"unit_of_measurement": "caches"
|
||||
},
|
||||
"favorite_points": {
|
||||
"name": "Favorite points",
|
||||
"unit_of_measurement": "points"
|
||||
},
|
||||
"souvenir_count": {
|
||||
"name": "Total souvenirs",
|
||||
"unit_of_measurement": "souvenirs"
|
||||
},
|
||||
"awarded_favorite_points": {
|
||||
"name": "Awarded favorite points",
|
||||
"unit_of_measurement": "points"
|
||||
},
|
||||
"cache_found_date": {
|
||||
"name": "Found date"
|
||||
},
|
||||
"cache_favorite_points": {
|
||||
"name": "Favorite points",
|
||||
"unit_of_measurement": "points"
|
||||
},
|
||||
"cache_hidden_date": {
|
||||
"name": "Hidden date"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["google-genai==1.29.0"]
|
||||
"requirements": ["google-genai==1.38.0"]
|
||||
}
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["govee-local-api==2.1.0"]
|
||||
"requirements": ["govee-local-api==2.2.0"]
|
||||
}
|
||||
|
@@ -112,11 +112,14 @@ PLACEHOLDER_KEY_ADDON = "addon"
|
||||
PLACEHOLDER_KEY_ADDON_URL = "addon_url"
|
||||
PLACEHOLDER_KEY_REFERENCE = "reference"
|
||||
PLACEHOLDER_KEY_COMPONENTS = "components"
|
||||
PLACEHOLDER_KEY_FREE_SPACE = "free_space"
|
||||
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail"
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing"
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed"
|
||||
ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
|
||||
|
||||
CORE_CONTAINER = "homeassistant"
|
||||
SUPERVISOR_CONTAINER = "hassio_supervisor"
|
||||
@@ -137,6 +140,24 @@ KEY_TO_UPDATE_TYPES: dict[str, set[str]] = {
|
||||
|
||||
REQUEST_REFRESH_DELAY = 10
|
||||
|
||||
HELP_URLS = {
|
||||
"help_url": "https://www.home-assistant.io/help/",
|
||||
"community_url": "https://community.home-assistant.io/",
|
||||
}
|
||||
|
||||
EXTRA_PLACEHOLDERS = {
|
||||
"issue_mount_mount_failed": {
|
||||
"storage_url": "/config/storage",
|
||||
},
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS,
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE: {
|
||||
"more_info_free_space": "https://www.home-assistant.io/more-info/free-space",
|
||||
},
|
||||
ISSUE_KEY_ADDON_PWNED: {
|
||||
"more_info_pwned": "https://www.home-assistant.io/more-info/pwned-passwords",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class SupervisorEntityModel(StrEnum):
|
||||
"""Supervisor entity model."""
|
||||
|
@@ -41,17 +41,21 @@ from .const import (
|
||||
EVENT_SUPERVISOR_EVENT,
|
||||
EVENT_SUPERVISOR_UPDATE,
|
||||
EVENT_SUPPORTED_CHANGED,
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||
PLACEHOLDER_KEY_ADDON,
|
||||
PLACEHOLDER_KEY_ADDON_URL,
|
||||
PLACEHOLDER_KEY_FREE_SPACE,
|
||||
PLACEHOLDER_KEY_REFERENCE,
|
||||
REQUEST_REFRESH_DELAY,
|
||||
UPDATE_KEY_SUPERVISOR,
|
||||
)
|
||||
from .coordinator import get_addons_info
|
||||
from .coordinator import get_addons_info, get_host_info
|
||||
from .handler import HassIO, get_supervisor_client
|
||||
|
||||
ISSUE_KEY_UNHEALTHY = "unhealthy"
|
||||
@@ -78,6 +82,8 @@ ISSUE_KEYS_FOR_REPAIRS = {
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
"issue_system_disk_lifetime",
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -241,11 +247,17 @@ class SupervisorIssues:
|
||||
def add_issue(self, issue: Issue) -> None:
|
||||
"""Add or update an issue in the list. Create or update a repair if necessary."""
|
||||
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
|
||||
placeholders: dict[str, str] | None = None
|
||||
if issue.reference:
|
||||
placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference}
|
||||
placeholders: dict[str, str] = {}
|
||||
if not issue.suggestions and issue.key in EXTRA_PLACEHOLDERS:
|
||||
placeholders |= EXTRA_PLACEHOLDERS[issue.key]
|
||||
|
||||
if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING:
|
||||
if issue.reference:
|
||||
placeholders[PLACEHOLDER_KEY_REFERENCE] = issue.reference
|
||||
|
||||
if issue.key in {
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
}:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON_URL] = (
|
||||
f"/hassio/addon/{issue.reference}"
|
||||
)
|
||||
@@ -257,6 +269,19 @@ class SupervisorIssues:
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference
|
||||
|
||||
elif issue.key == ISSUE_KEY_SYSTEM_FREE_SPACE:
|
||||
host_info = get_host_info(self._hass)
|
||||
if (
|
||||
host_info
|
||||
and "data" in host_info
|
||||
and "disk_free" in host_info["data"]
|
||||
):
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = str(
|
||||
host_info["data"]["disk_free"]
|
||||
)
|
||||
else:
|
||||
placeholders[PLACEHOLDER_KEY_FREE_SPACE] = "<2"
|
||||
|
||||
async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
@@ -264,7 +289,7 @@ class SupervisorIssues:
|
||||
is_fixable=bool(issue.suggestions),
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=issue.key,
|
||||
translation_placeholders=placeholders,
|
||||
translation_placeholders=placeholders or None,
|
||||
)
|
||||
|
||||
self._issues[issue.uuid] = issue
|
||||
|
@@ -16,8 +16,10 @@ from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_info, get_issues_info
|
||||
from .const import (
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
PLACEHOLDER_KEY_ADDON,
|
||||
PLACEHOLDER_KEY_COMPONENTS,
|
||||
@@ -26,11 +28,6 @@ from .const import (
|
||||
from .handler import get_supervisor_client
|
||||
from .issues import Issue, Suggestion
|
||||
|
||||
HELP_URLS = {
|
||||
"help_url": "https://www.home-assistant.io/help/",
|
||||
"community_url": "https://community.home-assistant.io/",
|
||||
}
|
||||
|
||||
SUGGESTION_CONFIRMATION_REQUIRED = {
|
||||
"addon_execute_remove",
|
||||
"system_adopt_data_disk",
|
||||
@@ -38,14 +35,6 @@ SUGGESTION_CONFIRMATION_REQUIRED = {
|
||||
}
|
||||
|
||||
|
||||
EXTRA_PLACEHOLDERS = {
|
||||
"issue_mount_mount_failed": {
|
||||
"storage_url": "/config/storage",
|
||||
},
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS,
|
||||
}
|
||||
|
||||
|
||||
class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
@@ -219,6 +208,7 @@ async def async_create_fix_flow(
|
||||
if issue and issue.key in {
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
}:
|
||||
return AddonIssueRepairFlow(hass, issue_id)
|
||||
|
||||
|
@@ -37,14 +37,14 @@
|
||||
},
|
||||
"issue_addon_detached_addon_missing": {
|
||||
"title": "Missing repository for an installed add-on",
|
||||
"description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store."
|
||||
"description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store."
|
||||
},
|
||||
"issue_addon_detached_addon_removed": {
|
||||
"title": "Installed add-on has been removed from repository",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"addon_execute_remove": {
|
||||
"description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
|
||||
"description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the Home Assistant Supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
@@ -52,6 +52,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issue_addon_pwned": {
|
||||
"title": "Insecure secrets detected in add-on configuration",
|
||||
"description": "Add-on {addon} uses secrets/passwords in its configuration which are detected as not secure. See [pwned passwords and secrets]({more_info_pwned}) for more information on this issue."
|
||||
},
|
||||
"issue_mount_mount_failed": {
|
||||
"title": "Network storage device failed",
|
||||
"fix_flow": {
|
||||
@@ -119,6 +123,10 @@
|
||||
"title": "Disk lifetime exceeding 90%",
|
||||
"description": "The data disk has exceeded 90% of its expected lifespan. The disk may soon malfunction which can lead to data loss. You should replace it soon and migrate your data."
|
||||
},
|
||||
"issue_system_free_space": {
|
||||
"title": "Data disk is running low on free space",
|
||||
"description": "The data disk has only {free_space}GB free space left. This may cause issues with system stability and interfere with functionality such as backups and updates. See [clear up storage]({more_info_free_space}) for tips on how to free up space."
|
||||
},
|
||||
"unhealthy": {
|
||||
"title": "Unhealthy system - {reason}",
|
||||
"description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more."
|
||||
@@ -185,7 +193,7 @@
|
||||
},
|
||||
"unsupported_docker_version": {
|
||||
"title": "Unsupported system - Docker version",
|
||||
"description": "System is unsupported because the wrong version of Docker is in use. Use the link to learn the correct version and how to fix this."
|
||||
"description": "System is unsupported because the Docker version is out of date. For information about the required version and how to fix this, select Learn more."
|
||||
},
|
||||
"unsupported_job_conditions": {
|
||||
"title": "Unsupported system - Protections disabled",
|
||||
@@ -201,7 +209,7 @@
|
||||
},
|
||||
"unsupported_os": {
|
||||
"title": "Unsupported system - Operating System",
|
||||
"description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. Use the link to which operating systems are supported and how to fix this."
|
||||
"description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. For information about supported operating systems and how to fix this, select Learn more."
|
||||
},
|
||||
"unsupported_os_agent": {
|
||||
"title": "Unsupported system - OS-Agent issues",
|
||||
|
23
homeassistant/components/history_stats/diagnostics.py
Normal file
23
homeassistant/components/history_stats/diagnostics.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Diagnostics support for history_stats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
registry = er.async_get(hass)
|
||||
entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
|
||||
|
||||
return {
|
||||
"config_entry": config_entry.as_dict(),
|
||||
"entity": [entity.extended_dict for entity in entities],
|
||||
}
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.79", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.81", "babel==2.15.0"]
|
||||
}
|
||||
|
@@ -12,13 +12,3 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe
|
||||
authorize_url=OAUTH2_AUTHORIZE,
|
||||
token_url=OAUTH2_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
|
||||
"""Return description placeholders for the credentials dialog."""
|
||||
return {
|
||||
"developer_dashboard_url": "https://developer.home-connect.com/",
|
||||
"applications_url": "https://developer.home-connect.com/applications",
|
||||
"register_application_url": "https://developer.home-connect.com/application/add",
|
||||
"redirect_url": "https://my.home-assistant.io/redirect/oauth",
|
||||
}
|
||||
|
@@ -659,17 +659,3 @@ class HomeConnectCoordinator(
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
async def reset_execution_tracker(self, appliance_ha_id: str) -> None:
|
||||
"""Reset the execution tracker for a specific appliance."""
|
||||
self._execution_tracker.pop(appliance_ha_id, None)
|
||||
appliance_info = await self.client.get_specific_appliance(appliance_ha_id)
|
||||
|
||||
appliance_data = await self._get_appliance_data(
|
||||
appliance_info, self.data.get(appliance_info.ha_id)
|
||||
)
|
||||
self.data[appliance_ha_id].update(appliance_data)
|
||||
for listener, context in self._special_listeners.values():
|
||||
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
|
||||
listener()
|
||||
self._call_all_event_listeners_for_appliance(appliance_ha_id)
|
||||
|
@@ -1,60 +0,0 @@
|
||||
"""Repairs flows for Home Connect."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .coordinator import HomeConnectConfigEntry
|
||||
|
||||
|
||||
class EnableApplianceUpdatesFlow(RepairsFlow):
|
||||
"""Handler for enabling appliance's updates after being refreshed too many times."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
assert self.data
|
||||
entry = self.hass.config_entries.async_get_entry(
|
||||
cast(str, self.data["entry_id"])
|
||||
)
|
||||
assert entry
|
||||
entry = cast(HomeConnectConfigEntry, entry)
|
||||
await entry.runtime_data.reset_execution_tracker(
|
||||
cast(str, self.data["appliance_ha_id"])
|
||||
)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
issue_registry = ir.async_get(self.hass)
|
||||
description_placeholders = None
|
||||
if issue := issue_registry.async_get_issue(self.handler, self.issue_id):
|
||||
description_placeholders = issue.translation_placeholders
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
if issue_id.startswith("home_connect_too_many_connected_paired_events"):
|
||||
return EnableApplianceUpdatesFlow()
|
||||
return ConfirmRepairFlow()
|
@@ -103,6 +103,7 @@ class HomeAssistantConnectZBT2ConfigFlow(
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
ZIGBEE_BAUDRATE = 460800
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initialize the config flow."""
|
||||
|
@@ -52,8 +52,16 @@
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
"menu_options": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -75,6 +83,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -111,7 +142,15 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
"menu_options": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -133,6 +172,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -23,6 +24,7 @@ from homeassistant.config_entries import (
|
||||
ConfigEntryBaseFlow,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
FlowType,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
@@ -48,13 +50,31 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_PICK_FIRMWARE_THREAD = "pick_firmware_thread"
|
||||
STEP_PICK_FIRMWARE_ZIGBEE = "pick_firmware_zigbee"
|
||||
STEP_PICK_FIRMWARE_THREAD_MIGRATE = "pick_firmware_thread_migrate"
|
||||
STEP_PICK_FIRMWARE_ZIGBEE_MIGRATE = "pick_firmware_zigbee_migrate"
|
||||
|
||||
|
||||
class PickedFirmwareType(StrEnum):
|
||||
"""Firmware types that can be picked."""
|
||||
|
||||
THREAD = "thread"
|
||||
ZIGBEE = "zigbee"
|
||||
|
||||
|
||||
class ZigbeeIntegration(StrEnum):
|
||||
"""Zigbee integrations that can be picked."""
|
||||
|
||||
OTHER = "other"
|
||||
ZHA = "zha"
|
||||
|
||||
|
||||
class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Base flow to install firmware."""
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||
_failed_addon_name: str
|
||||
_failed_addon_reason: str
|
||||
_picked_firmware_type: PickedFirmwareType
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Instantiate base flow."""
|
||||
@@ -63,6 +83,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self._probed_firmware_info: FirmwareInfo | None = None
|
||||
self._device: str | None = None # To be set in a subclass
|
||||
self._hardware_name: str = "unknown" # To be set in a subclass
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
|
||||
self.addon_install_task: asyncio.Task | None = None
|
||||
self.addon_start_task: asyncio.Task | None = None
|
||||
@@ -105,11 +126,23 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Thread or Zigbee firmware."""
|
||||
# Determine if ZHA or Thread are already configured to present migrate options
|
||||
zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN)
|
||||
otbr_entries = self.hass.config_entries.async_entries(OTBR_DOMAIN)
|
||||
|
||||
return self.async_show_menu(
|
||||
step_id="pick_firmware",
|
||||
menu_options=[
|
||||
STEP_PICK_FIRMWARE_ZIGBEE,
|
||||
STEP_PICK_FIRMWARE_THREAD,
|
||||
(
|
||||
STEP_PICK_FIRMWARE_ZIGBEE_MIGRATE
|
||||
if zha_entries
|
||||
else STEP_PICK_FIRMWARE_ZIGBEE
|
||||
),
|
||||
(
|
||||
STEP_PICK_FIRMWARE_THREAD_MIGRATE
|
||||
if otbr_entries
|
||||
else STEP_PICK_FIRMWARE_THREAD
|
||||
),
|
||||
],
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
@@ -255,6 +288,45 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
async def _configure_and_start_otbr_addon(self) -> None:
|
||||
"""Configure and start the OTBR addon."""
|
||||
|
||||
# Before we start the addon, confirm that the correct firmware is running
|
||||
# and populate `self._probed_firmware_info` with the correct information
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
|
||||
raise AbortFlow(
|
||||
"unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
assert self._device is not None
|
||||
new_addon_config = {
|
||||
**addon_info.options,
|
||||
"device": self._device,
|
||||
"baudrate": 460800,
|
||||
"flow_control": True,
|
||||
"autoflash_firmware": False,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
|
||||
|
||||
try:
|
||||
await otbr_manager.async_set_addon_options(new_addon_config)
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow(
|
||||
"addon_set_config_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": otbr_manager.addon_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
await otbr_manager.async_start_addon_waiting()
|
||||
|
||||
async def async_step_firmware_download_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -281,17 +353,85 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_pick_firmware_zigbee(
|
||||
async def async_step_zigbee_installation_type(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Zigbee firmware."""
|
||||
"""Handle the installation type step."""
|
||||
return self.async_show_menu(
|
||||
step_id="zigbee_installation_type",
|
||||
menu_options=[
|
||||
"zigbee_intent_recommended",
|
||||
"zigbee_intent_custom",
|
||||
],
|
||||
)
|
||||
|
||||
async def async_step_zigbee_intent_recommended(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select recommended installation type."""
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
async def async_step_zigbee_intent_custom(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select custom installation type."""
|
||||
return await self.async_step_zigbee_integration()
|
||||
|
||||
async def async_step_zigbee_integration(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select Zigbee integration."""
|
||||
return self.async_show_menu(
|
||||
step_id="zigbee_integration",
|
||||
menu_options=[
|
||||
"zigbee_integration_zha",
|
||||
"zigbee_integration_other",
|
||||
],
|
||||
)
|
||||
|
||||
async def async_step_zigbee_integration_zha(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select ZHA integration."""
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
async def async_step_zigbee_integration_other(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Select other Zigbee integration."""
|
||||
self._zigbee_integration = ZigbeeIntegration.OTHER
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
async def _async_continue_picked_firmware(self) -> ConfigFlowResult:
|
||||
"""Continue to the picked firmware step."""
|
||||
if not await self._probe_firmware_info():
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
return await self.async_step_install_zigbee_firmware()
|
||||
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
|
||||
return await self.async_step_install_zigbee_firmware()
|
||||
|
||||
if result := await self._ensure_thread_addon_setup():
|
||||
return result
|
||||
|
||||
return await self.async_step_install_thread_firmware()
|
||||
|
||||
async def async_step_pick_firmware_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Zigbee firmware."""
|
||||
self._picked_firmware_type = PickedFirmwareType.ZIGBEE
|
||||
return await self.async_step_zigbee_installation_type()
|
||||
|
||||
async def async_step_pick_firmware_zigbee_migrate(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Zigbee firmware. Migration is automatic."""
|
||||
return await self.async_step_pick_firmware_zigbee()
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -317,42 +457,43 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Pre-confirm Zigbee setup."""
|
||||
|
||||
# This step is necessary to prevent `user_input` from being passed through
|
||||
return await self.async_step_confirm_zigbee()
|
||||
return await self.async_step_continue_zigbee()
|
||||
|
||||
async def async_step_confirm_zigbee(
|
||||
async def async_step_continue_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm Zigbee setup."""
|
||||
"""Continue Zigbee setup."""
|
||||
assert self._device is not None
|
||||
assert self._hardware_name is not None
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="confirm_zigbee",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
await self.hass.config_entries.flow.async_init(
|
||||
if self._zigbee_integration == ZigbeeIntegration.OTHER:
|
||||
return self._async_flow_finished()
|
||||
|
||||
result = await self.hass.config_entries.flow.async_init(
|
||||
ZHA_DOMAIN,
|
||||
context={"source": "hardware"},
|
||||
data={
|
||||
"name": self._hardware_name,
|
||||
"port": {
|
||||
"path": self._device,
|
||||
"baudrate": 115200,
|
||||
"baudrate": self.ZIGBEE_BAUDRATE,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
},
|
||||
)
|
||||
return self._continue_zha_flow(result)
|
||||
|
||||
return self._async_flow_finished()
|
||||
@callback
|
||||
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
|
||||
"""Continue the ZHA flow."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None:
|
||||
"""Ensure the OTBR addon is set up and not running."""
|
||||
@@ -371,18 +512,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
return await self.async_step_install_otbr_addon()
|
||||
|
||||
if addon_info.state == AddonState.RUNNING:
|
||||
# We only fail setup if we have an instance of OTBR running *and* it's
|
||||
# pointing to different hardware
|
||||
if addon_info.options["device"] != self._device:
|
||||
return self.async_abort(
|
||||
reason="otbr_addon_already_running",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": otbr_manager.addon_name,
|
||||
},
|
||||
)
|
||||
|
||||
# Otherwise, stop the addon before continuing to flash firmware
|
||||
# Stop the addon before continuing to flash firmware
|
||||
await otbr_manager.async_stop_addon()
|
||||
|
||||
return None
|
||||
@@ -391,16 +521,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Thread firmware."""
|
||||
if not await self._probe_firmware_info():
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
self._picked_firmware_type = PickedFirmwareType.THREAD
|
||||
return await self._async_continue_picked_firmware()
|
||||
|
||||
if result := await self._ensure_thread_addon_setup():
|
||||
return result
|
||||
|
||||
return await self.async_step_install_thread_firmware()
|
||||
async def async_step_pick_firmware_thread_migrate(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Thread firmware. Migration is automatic."""
|
||||
return await self.async_step_pick_firmware_thread()
|
||||
|
||||
async def async_step_install_thread_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -453,43 +581,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
|
||||
if not self.addon_start_task:
|
||||
# Before we start the addon, confirm that the correct firmware is running
|
||||
# and populate `self._probed_firmware_info` with the correct information
|
||||
if not await self._probe_firmware_info(
|
||||
probe_methods=(ApplicationType.SPINEL,)
|
||||
):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
assert self._device is not None
|
||||
new_addon_config = {
|
||||
**addon_info.options,
|
||||
"device": self._device,
|
||||
"baudrate": 460800,
|
||||
"flow_control": True,
|
||||
"autoflash_firmware": False,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
|
||||
|
||||
try:
|
||||
await otbr_manager.async_set_addon_options(new_addon_config)
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow(
|
||||
"addon_set_config_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": otbr_manager.addon_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
self.addon_start_task = self.hass.async_create_task(
|
||||
otbr_manager.async_start_addon_waiting()
|
||||
self._configure_and_start_otbr_addon()
|
||||
)
|
||||
|
||||
if not self.addon_start_task.done():
|
||||
@@ -508,7 +601,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
except (AddonError, AbortFlow) as err:
|
||||
_LOGGER.error(err)
|
||||
self._failed_addon_name = otbr_manager.addon_name
|
||||
self._failed_addon_reason = "addon_start_failed"
|
||||
self._failed_addon_reason = (
|
||||
err.reason if isinstance(err, AbortFlow) else "addon_start_failed"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id="addon_operation_failed")
|
||||
finally:
|
||||
self.addon_start_task = None
|
||||
@@ -572,6 +667,21 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
|
||||
|
||||
return await self.async_step_pick_firmware()
|
||||
|
||||
@callback
|
||||
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
|
||||
"""Continue the ZHA flow."""
|
||||
next_flow_id = zha_result["flow_id"]
|
||||
|
||||
result = self._async_flow_finished()
|
||||
return (
|
||||
self.async_create_entry(
|
||||
title=result["title"] or self._hardware_name,
|
||||
data=result["data"],
|
||||
next_flow=(FlowType.CONFIG_FLOW, next_flow_id),
|
||||
)
|
||||
| result # update all items with the child result
|
||||
)
|
||||
|
||||
|
||||
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
|
||||
"""Zigbee and Thread options flow handlers."""
|
||||
@@ -629,3 +739,10 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
|
||||
)
|
||||
|
||||
return await super().async_step_pick_firmware_thread(user_input)
|
||||
|
||||
@callback
|
||||
def _continue_zha_flow(self, zha_result: ConfigFlowResult) -> ConfigFlowResult:
|
||||
"""Continue the ZHA flow."""
|
||||
# The options flow cannot return a next_flow yet, so we just finish here.
|
||||
# The options flow should be changed to a reconfigure flow.
|
||||
return self._async_flow_finished()
|
||||
|
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"requirements": [
|
||||
"universal-silabs-flasher==0.0.31",
|
||||
"universal-silabs-flasher==0.0.32",
|
||||
"ha-silabs-firmware-client==0.2.0"
|
||||
]
|
||||
}
|
||||
|
@@ -3,11 +3,19 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"pick_firmware": {
|
||||
"title": "Pick your firmware",
|
||||
"description": "Let's get started with setting up your {model}. Do you want to use it to set up a Zigbee or Thread network?",
|
||||
"title": "Pick your protocol",
|
||||
"description": "You can use your {model} for a Zigbee or Thread network. Please check what type of devices you want to add to Home Assistant. You can always change this later.",
|
||||
"menu_options": {
|
||||
"pick_firmware_zigbee": "Zigbee",
|
||||
"pick_firmware_thread": "Thread"
|
||||
"pick_firmware_zigbee": "Use as Zigbee adapter",
|
||||
"pick_firmware_thread": "Use as Thread adapter",
|
||||
"pick_firmware_zigbee_migrate": "Migrate Zigbee to a new adapter",
|
||||
"pick_firmware_thread_migrate": "Migrate Thread to a new adapter"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "Most common protocol.",
|
||||
"pick_firmware_thread": "Often used for Matter over Thread devices.",
|
||||
"pick_firmware_zigbee_migrate": "This will move your Zigbee network to the new adapter.",
|
||||
"pick_firmware_thread_migrate": "This will migrate your Thread Border Router to the new adapter."
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -29,6 +37,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "OpenThread Border Router setup complete",
|
||||
"description": "Your {model} is now an OpenThread Border Router and will show up in the Thread integration."
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "Set up Zigbee",
|
||||
"description": "Choose the installation type for the Zigbee adapter.",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "Recommended installation",
|
||||
"zigbee_intent_custom": "Custom"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "Automatically install and configure Zigbee.",
|
||||
"zigbee_intent_custom": "Manually install and configure Zigbee, for example with Zigbee2MQTT."
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "Select Zigbee method",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "Zigbee Home Automation",
|
||||
"zigbee_integration_other": "Other"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "Lets Home Assistant control a Zigbee network.",
|
||||
"zigbee_integration_other": "For example if you want to use the adapter with Zigbee2MQTT."
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
|
@@ -52,8 +52,16 @@
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
"menu_options": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -75,6 +83,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -111,7 +142,15 @@
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
"menu_options": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -133,6 +172,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
|
@@ -92,7 +92,7 @@ class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
firmware_name="Zigbee",
|
||||
expected_installed_firmware_type=ApplicationType.EZSP,
|
||||
step_id="install_zigbee_firmware",
|
||||
next_step_id="confirm_zigbee",
|
||||
next_step_id="pre_confirm_zigbee",
|
||||
)
|
||||
|
||||
async def async_step_install_thread_firmware(
|
||||
|
@@ -75,8 +75,16 @@
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
"menu_options": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread_migrate%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee%]",
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread%]",
|
||||
"pick_firmware_zigbee_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_zigbee_migrate%]",
|
||||
"pick_firmware_thread_migrate": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_option_descriptions::pick_firmware_thread_migrate%]"
|
||||
}
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
@@ -98,6 +106,29 @@
|
||||
"confirm_otbr": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_otbr::description%]"
|
||||
},
|
||||
"zigbee_installation_type": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::description%]",
|
||||
"menu_options": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_options::zigbee_intent_custom%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_intent_recommended": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_recommended%]",
|
||||
"zigbee_intent_custom": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_installation_type::menu_option_descriptions::zigbee_intent_custom%]"
|
||||
}
|
||||
},
|
||||
"zigbee_integration": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::title%]",
|
||||
"menu_options": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_options::zigbee_integration_other%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"zigbee_integration_zha": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_zha%]",
|
||||
"zigbee_integration_other": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_integration::menu_option_descriptions::zigbee_integration_other%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohomekit", "commentjson"],
|
||||
"requirements": ["aiohomekit==3.2.16"],
|
||||
"requirements": ["aiohomekit==3.2.17"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
||||
}
|
||||
|
@@ -36,6 +36,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
|
||||
@@ -66,15 +67,13 @@ def to_percentage(value: float | None) -> float | None:
|
||||
return value * 100 if value is not None else None
|
||||
|
||||
|
||||
def time_to_datetime(value: int | None) -> datetime | None:
|
||||
"""Convert seconds to datetime when value is not None."""
|
||||
return (
|
||||
utcnow().replace(microsecond=0) - timedelta(seconds=value)
|
||||
if value is not None
|
||||
else None
|
||||
)
|
||||
def uptime_to_datetime(value: int) -> datetime:
|
||||
"""Convert seconds to datetime timestamp."""
|
||||
return utcnow().replace(microsecond=0) - timedelta(seconds=value)
|
||||
|
||||
|
||||
uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5))
|
||||
|
||||
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="smr_version",
|
||||
@@ -647,7 +646,11 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
lambda data: data.system is not None and data.system.uptime_s is not None
|
||||
),
|
||||
value_fn=(
|
||||
lambda data: time_to_datetime(data.system.uptime_s) if data.system else None
|
||||
lambda data: (
|
||||
uptime_to_stable_datetime(data.system.uptime_s)
|
||||
if data.system is not None and data.system.uptime_s is not None
|
||||
else None
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
@@ -18,6 +18,6 @@
|
||||
},
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiopvapi"],
|
||||
"requirements": ["aiopvapi==3.1.1"],
|
||||
"requirements": ["aiopvapi==3.2.1"],
|
||||
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
|
||||
}
|
||||
|
@@ -21,6 +21,21 @@ from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
BLUETOOTH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN): str,
|
||||
}
|
||||
)
|
||||
|
||||
USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): str,
|
||||
vol.Required(CONF_PIN): str,
|
||||
}
|
||||
)
|
||||
|
||||
REAUTH_SCHEMA = BLUETOOTH_SCHEMA
|
||||
|
||||
|
||||
def _is_supported(discovery_info: BluetoothServiceInfo):
|
||||
"""Check if device is supported."""
|
||||
@@ -78,6 +93,10 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not _is_supported(discovery_info):
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
"name": discovery_info.name,
|
||||
"address": discovery_info.address,
|
||||
}
|
||||
self.address = discovery_info.address
|
||||
await self.async_set_unique_id(self.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -100,12 +119,7 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
user_input,
|
||||
BLUETOOTH_SCHEMA, user_input
|
||||
),
|
||||
description_placeholders={"name": self.mower_name or self.address},
|
||||
errors=errors,
|
||||
@@ -129,15 +143,7 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): str,
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
user_input,
|
||||
),
|
||||
data_schema=self.add_suggested_values_to_schema(USER_SCHEMA, user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -184,7 +190,24 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
title = await self.probe_mower(device)
|
||||
if title is None:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
if self.source == SOURCE_BLUETOOTH:
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
data_schema=BLUETOOTH_SCHEMA,
|
||||
description_placeholders={"name": self.address},
|
||||
errors={"base": "cannot_connect"},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
USER_SCHEMA,
|
||||
{
|
||||
CONF_ADDRESS: self.address,
|
||||
CONF_PIN: self.pin,
|
||||
},
|
||||
),
|
||||
errors={"base": "cannot_connect"},
|
||||
)
|
||||
self.mower_name = title
|
||||
|
||||
try:
|
||||
@@ -209,11 +232,7 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if self.source == SOURCE_BLUETOOTH:
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
data_schema=BLUETOOTH_SCHEMA,
|
||||
description_placeholders={
|
||||
"name": self.mower_name or self.address
|
||||
},
|
||||
@@ -230,13 +249,7 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): str,
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
suggested_values,
|
||||
USER_SCHEMA, suggested_values
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
@@ -312,12 +325,7 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PIN): str,
|
||||
},
|
||||
),
|
||||
{CONF_PIN: self.pin},
|
||||
REAUTH_SCHEMA, {CONF_PIN: self.pin}
|
||||
),
|
||||
description_placeholders={"name": self.mower_name},
|
||||
errors=errors,
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/icloud",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["keyrings.alt", "pyicloud"],
|
||||
"requirements": ["pyicloud==1.0.0"]
|
||||
"requirements": ["pyicloud==2.0.3"]
|
||||
}
|
||||
|
@@ -6,7 +6,7 @@
|
||||
"description": "Enter your credentials",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::email%]",
|
||||
"password": "App-specific password",
|
||||
"password": "Main password (MFA)",
|
||||
"with_family": "With family"
|
||||
}
|
||||
},
|
||||
@@ -14,7 +14,8 @@
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "Your previously entered password for {username} is no longer working. Update your password to keep using this integration.",
|
||||
"data": {
|
||||
"password": "App-specific password"
|
||||
"username": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:component::icloud::config::step::user::data::password%]"
|
||||
}
|
||||
},
|
||||
"trusted_device": {
|
||||
@@ -25,8 +26,8 @@
|
||||
}
|
||||
},
|
||||
"verification_code": {
|
||||
"title": "iCloud verification code",
|
||||
"description": "Please enter the verification code you just received from iCloud",
|
||||
"title": "Apple Account code",
|
||||
"description": "Please enter the verification code you just received from Apple",
|
||||
"data": {
|
||||
"verification_code": "Verification code"
|
||||
}
|
||||
@@ -39,18 +40,18 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"no_device": "None of your devices have \"Find my iPhone\" activated",
|
||||
"no_device": "None of your devices have \"Find My\" activated",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"update": {
|
||||
"name": "Update",
|
||||
"description": "Asks for a state update of all devices linked to an iCloud account.",
|
||||
"description": "Asks for a state update of all devices linked to an Apple Account.",
|
||||
"fields": {
|
||||
"account": {
|
||||
"name": "Account",
|
||||
"description": "Your iCloud account username (email) or account name."
|
||||
"description": "Your Apple Account username (email)."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -105,6 +105,20 @@ async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image:
|
||||
raise HomeAssistantError("Unable to get image")
|
||||
|
||||
|
||||
async def async_get_image(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
timeout: int = 10,
|
||||
) -> Image:
|
||||
"""Fetch an image from an image entity."""
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
|
||||
if (image := component.get_entity(entity_id)) is None:
|
||||
raise HomeAssistantError(f"Image entity {entity_id} not found")
|
||||
|
||||
return await _async_get_image(image, timeout)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the image component."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[ImageEntity](
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["imeon_inverter_api==0.3.16"],
|
||||
"requirements": ["imeon_inverter_api==0.4.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "IMEON",
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["imgw_pib==1.5.4"]
|
||||
"requirements": ["imgw_pib==1.5.6"]
|
||||
}
|
||||
|
@@ -42,7 +42,7 @@
|
||||
"characteristic_missing": "The device is either already connected to Wi-Fi, or no longer able to connect to Wi-Fi. If you want to connect it to another network, try factory resetting it first.",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
|
||||
"provision_successful": "The device has successfully connected to the Wi-Fi network.",
|
||||
"provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease visit {url} to finish setup.",
|
||||
"provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease finish the setup by following the [setup instructions]({url}).",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
}
|
||||
|
@@ -118,27 +118,31 @@ COVER_KNX_SCHEMA = AllSerializeFirst(
|
||||
vol.Schema(
|
||||
{
|
||||
"section_binary_control": KNXSectionFlat(),
|
||||
vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False, valid_dpt="1"),
|
||||
vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(),
|
||||
"section_stop_control": KNXSectionFlat(),
|
||||
vol.Optional(CONF_GA_STOP): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_STEP): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_STOP): GASelector(state=False, valid_dpt="1"),
|
||||
vol.Optional(CONF_GA_STEP): GASelector(state=False, valid_dpt="1"),
|
||||
"section_position_control": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False),
|
||||
vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False),
|
||||
vol.Optional(CONF_GA_POSITION_SET): GASelector(
|
||||
state=False, valid_dpt="5.001"
|
||||
),
|
||||
vol.Optional(CONF_GA_POSITION_STATE): GASelector(
|
||||
write=False, valid_dpt="5.001"
|
||||
),
|
||||
vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(),
|
||||
"section_tilt_control": KNXSectionFlat(collapsible=True),
|
||||
vol.Optional(CONF_GA_ANGLE): GASelector(),
|
||||
vol.Optional(CONF_GA_ANGLE): GASelector(valid_dpt="5.001"),
|
||||
vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(),
|
||||
"section_travel_time": KNXSectionFlat(),
|
||||
vol.Optional(
|
||||
vol.Required(
|
||||
CoverConf.TRAVELLING_TIME_UP, default=25
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0, max=1000, step=0.1, unit_of_measurement="s"
|
||||
)
|
||||
),
|
||||
vol.Optional(
|
||||
vol.Required(
|
||||
CoverConf.TRAVELLING_TIME_DOWN, default=25
|
||||
): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
@@ -310,7 +314,7 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
|
||||
SWITCH_KNX_SCHEMA = vol.Schema(
|
||||
{
|
||||
"section_switch": KNXSectionFlat(),
|
||||
vol.Required(CONF_GA_SWITCH): GASelector(write_required=True),
|
||||
vol.Required(CONF_GA_SWITCH): GASelector(write_required=True, valid_dpt="1"),
|
||||
vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
|
||||
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
|
||||
|
@@ -2,7 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from packaging import version
|
||||
from pylamarzocco import (
|
||||
LaMarzoccoBluetoothClient,
|
||||
@@ -11,6 +13,7 @@ from pylamarzocco import (
|
||||
)
|
||||
from pylamarzocco.const import FirmwareType
|
||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||
from pylamarzocco.util import InstallationKey, generate_installation_key
|
||||
|
||||
from homeassistant.components.bluetooth import async_discovered_service_info
|
||||
from homeassistant.const import (
|
||||
@@ -19,13 +22,14 @@ from homeassistant.const import (
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
__version__,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .coordinator import (
|
||||
LaMarzoccoConfigEntry,
|
||||
LaMarzoccoConfigUpdateCoordinator,
|
||||
@@ -60,7 +64,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
client=async_create_clientsession(hass),
|
||||
installation_key=InstallationKey.from_json(entry.data[CONF_INSTALLATION_KEY]),
|
||||
client=create_client_session(hass),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -166,45 +171,50 @@ async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate config entry."""
|
||||
if entry.version > 3:
|
||||
if entry.version > 4:
|
||||
# guard against downgrade from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 1:
|
||||
if entry.version in (1, 2):
|
||||
_LOGGER.error(
|
||||
"Migration from version 1 is no longer supported, please remove and re-add the integration"
|
||||
"Migration from version 1 or 2 is no longer supported, please remove and re-add the integration"
|
||||
)
|
||||
return False
|
||||
|
||||
if entry.version == 2:
|
||||
if entry.version == 3:
|
||||
installation_key = generate_installation_key(str(uuid.uuid4()).lower())
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
installation_key=installation_key,
|
||||
client=create_client_session(hass),
|
||||
)
|
||||
try:
|
||||
things = await cloud_client.list_things()
|
||||
await cloud_client.async_register_client()
|
||||
except (AuthFail, RequestNotSuccessful) as exc:
|
||||
_LOGGER.error("Migration failed with error %s", exc)
|
||||
return False
|
||||
v3_data = {
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry.data[CONF_PASSWORD],
|
||||
CONF_TOKEN: next(
|
||||
(
|
||||
thing.ble_auth_token
|
||||
for thing in things
|
||||
if thing.serial_number == entry.unique_id
|
||||
),
|
||||
None,
|
||||
),
|
||||
}
|
||||
if CONF_MAC in entry.data:
|
||||
v3_data[CONF_MAC] = entry.data[CONF_MAC]
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=v3_data,
|
||||
version=3,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_INSTALLATION_KEY: installation_key.to_json(),
|
||||
},
|
||||
version=4,
|
||||
)
|
||||
_LOGGER.debug("Migrated La Marzocco config entry to version 2")
|
||||
_LOGGER.debug("Migrated La Marzocco config entry to version 4")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_client_session(hass: HomeAssistant) -> ClientSession:
|
||||
"""Create a ClientSession with La Marzocco specific headers."""
|
||||
|
||||
return async_create_clientsession(
|
||||
hass,
|
||||
headers={
|
||||
"X-Client": "HOME_ASSISTANT",
|
||||
"X-Client-Build": __version__,
|
||||
},
|
||||
)
|
||||
|
@@ -5,11 +5,13 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from pylamarzocco import LaMarzoccoCloudClient
|
||||
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
|
||||
from pylamarzocco.models import Thing
|
||||
from pylamarzocco.util import InstallationKey, generate_installation_key
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
@@ -33,7 +35,6 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
@@ -45,7 +46,8 @@ from homeassistant.helpers.selector import (
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_USE_BLUETOOTH, DOMAIN
|
||||
from . import create_client_session
|
||||
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .coordinator import LaMarzoccoConfigEntry
|
||||
|
||||
CONF_MACHINE = "machine"
|
||||
@@ -57,9 +59,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for La Marzocco."""
|
||||
|
||||
VERSION = 3
|
||||
VERSION = 4
|
||||
|
||||
_client: ClientSession
|
||||
_installation_key: InstallationKey
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
@@ -83,13 +86,18 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
**user_input,
|
||||
}
|
||||
|
||||
self._client = async_create_clientsession(self.hass)
|
||||
self._client = create_client_session(self.hass)
|
||||
self._installation_key = generate_installation_key(
|
||||
str(uuid.uuid4()).lower()
|
||||
)
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=data[CONF_USERNAME],
|
||||
password=data[CONF_PASSWORD],
|
||||
client=self._client,
|
||||
installation_key=self._installation_key,
|
||||
)
|
||||
try:
|
||||
await cloud_client.async_register_client()
|
||||
things = await cloud_client.list_things()
|
||||
except AuthFail:
|
||||
_LOGGER.debug("Server rejected login credentials")
|
||||
@@ -184,6 +192,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=selected_device.name,
|
||||
data={
|
||||
**self._config,
|
||||
CONF_INSTALLATION_KEY: self._installation_key.to_json(),
|
||||
CONF_TOKEN: self._things[serial_number].ble_auth_token,
|
||||
},
|
||||
)
|
||||
|
@@ -5,3 +5,4 @@ from typing import Final
|
||||
DOMAIN: Final = "lamarzocco"
|
||||
|
||||
CONF_USE_BLUETOOTH: Final = "use_bluetooth"
|
||||
CONF_INSTALLATION_KEY: Final = "installation_key"
|
||||
|
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.0.11"]
|
||||
"requirements": ["pylamarzocco==2.1.0"]
|
||||
}
|
||||
|
@@ -209,5 +209,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_entity": {
|
||||
"title": "{name} is deprecated",
|
||||
"description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -6,13 +6,24 @@ from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic
|
||||
|
||||
from pylitterbot import FeederRobot, LitterRobot, Robot
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.components.switch import (
|
||||
DOMAIN as SWITCH_DOMAIN,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LitterRobotConfigEntry
|
||||
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||
|
||||
@@ -26,6 +37,15 @@ class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEnti
|
||||
value_fn: Callable[[_WhiskerEntityT], bool]
|
||||
|
||||
|
||||
NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION = RobotSwitchEntityDescription[
|
||||
LitterRobot | FeederRobot
|
||||
](
|
||||
key="night_light_mode_enabled",
|
||||
translation_key="night_light_mode",
|
||||
set_fn=lambda robot, value: robot.set_night_light(value),
|
||||
value_fn=lambda robot: robot.night_light_mode_enabled,
|
||||
)
|
||||
|
||||
SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = {
|
||||
FeederRobot: (
|
||||
RobotSwitchEntityDescription[FeederRobot](
|
||||
@@ -34,14 +54,10 @@ SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = {
|
||||
set_fn=lambda robot, value: robot.set_gravity_mode(value),
|
||||
value_fn=lambda robot: robot.gravity_mode_enabled,
|
||||
),
|
||||
NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,
|
||||
),
|
||||
LitterRobot3: (NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,),
|
||||
Robot: ( # type: ignore[type-abstract] # only used for isinstance check
|
||||
RobotSwitchEntityDescription[LitterRobot | FeederRobot](
|
||||
key="night_light_mode_enabled",
|
||||
translation_key="night_light_mode",
|
||||
set_fn=lambda robot, value: robot.set_night_light(value),
|
||||
value_fn=lambda robot: robot.night_light_mode_enabled,
|
||||
),
|
||||
RobotSwitchEntityDescription[LitterRobot | FeederRobot](
|
||||
key="panel_lock_enabled",
|
||||
translation_key="panel_lockout",
|
||||
@@ -59,13 +75,54 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Litter-Robot switches using config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
entities = [
|
||||
RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description)
|
||||
for robot in coordinator.account.robots
|
||||
for robot_type, entity_descriptions in SWITCH_MAP.items()
|
||||
if isinstance(robot, robot_type)
|
||||
for description in entity_descriptions
|
||||
)
|
||||
]
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
def add_deprecated_entity(
|
||||
robot: LitterRobot4,
|
||||
description: RobotSwitchEntityDescription,
|
||||
entity_cls: type[RobotSwitchEntity],
|
||||
) -> None:
|
||||
"""Add deprecated entities."""
|
||||
unique_id = f"{robot.serial}-{description.key}"
|
||||
if entity_id := ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id):
|
||||
entity_entry = ent_reg.async_get(entity_id)
|
||||
if entity_entry and entity_entry.disabled:
|
||||
ent_reg.async_remove(entity_id)
|
||||
async_delete_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_entity_{unique_id}",
|
||||
)
|
||||
elif entity_entry:
|
||||
entities.append(entity_cls(robot, coordinator, description))
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_entity_{unique_id}",
|
||||
breaks_in_ha_version="2026.4.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_entity",
|
||||
translation_placeholders={
|
||||
"name": f"{robot.name} {entity_entry.name or entity_entry.original_name}",
|
||||
"entity": entity_id,
|
||||
},
|
||||
)
|
||||
|
||||
for robot in coordinator.account.get_robots(LitterRobot4):
|
||||
add_deprecated_entity(
|
||||
robot, NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, RobotSwitchEntity
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
|
||||
|
@@ -132,7 +132,7 @@ class LocalTodoListEntity(TodoListEntity):
|
||||
self._store = store
|
||||
self._calendar = calendar
|
||||
self._calendar_lock = asyncio.Lock()
|
||||
self._attr_name = name.capitalize()
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
def _new_todo_store(self) -> TodoStore:
|
||||
|
@@ -8,7 +8,7 @@ import logging
|
||||
import ssl
|
||||
from typing import Any, cast
|
||||
|
||||
from pylutron_caseta import BUTTON_STATUS_PRESSED
|
||||
from pylutron_caseta import BUTTON_STATUS_MULTITAP, BUTTON_STATUS_PRESSED
|
||||
from pylutron_caseta.smartbridge import Smartbridge
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -25,6 +25,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ACTION_MULTITAP,
|
||||
ACTION_PRESS,
|
||||
ACTION_RELEASE,
|
||||
ATTR_ACTION,
|
||||
@@ -448,6 +449,8 @@ def _async_subscribe_keypad_events(
|
||||
|
||||
if event_type == BUTTON_STATUS_PRESSED:
|
||||
action = ACTION_PRESS
|
||||
elif event_type == BUTTON_STATUS_MULTITAP:
|
||||
action = ACTION_MULTITAP
|
||||
else:
|
||||
action = ACTION_RELEASE
|
||||
|
||||
|
@@ -29,6 +29,7 @@ ATTR_DEVICE_NAME = "device_name"
|
||||
ATTR_AREA_NAME = "area_name"
|
||||
ATTR_ACTION = "action"
|
||||
|
||||
ACTION_MULTITAP = "multi_tap"
|
||||
ACTION_PRESS = "press"
|
||||
ACTION_RELEASE = "release"
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""Support for Lutron Caseta shades."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
@@ -17,6 +18,14 @@ from .entity import LutronCasetaUpdatableEntity
|
||||
from .models import LutronCasetaConfigEntry
|
||||
|
||||
|
||||
class ShadeMovementDirection(Enum):
|
||||
"""Enum for shade movement direction."""
|
||||
|
||||
OPENING = "opening"
|
||||
CLOSING = "closing"
|
||||
STOPPED = "stopped"
|
||||
|
||||
|
||||
class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity):
|
||||
"""Representation of a Lutron shade with open/close functionality."""
|
||||
|
||||
@@ -27,6 +36,8 @@ class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity):
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
)
|
||||
_attr_device_class = CoverDeviceClass.SHADE
|
||||
_previous_position: int | None = None
|
||||
_movement_direction: ShadeMovementDirection | None = None
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
@@ -38,19 +49,50 @@ class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity):
|
||||
"""Return the current position of cover."""
|
||||
return self._device["current_state"]
|
||||
|
||||
def _handle_bridge_update(self) -> None:
|
||||
"""Handle updated data from the bridge and track movement direction."""
|
||||
current_position = self.current_cover_position
|
||||
|
||||
# Track movement direction based on position changes or endpoint status
|
||||
if self._previous_position is not None:
|
||||
if current_position > self._previous_position or current_position >= 100:
|
||||
# Moving up or at fully open
|
||||
self._movement_direction = ShadeMovementDirection.OPENING
|
||||
elif current_position < self._previous_position or current_position <= 0:
|
||||
# Moving down or at fully closed
|
||||
self._movement_direction = ShadeMovementDirection.CLOSING
|
||||
else:
|
||||
# Stopped
|
||||
self._movement_direction = ShadeMovementDirection.STOPPED
|
||||
|
||||
self._previous_position = current_position
|
||||
super()._handle_bridge_update()
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
await self._smartbridge.lower_cover(self.device_id)
|
||||
# Use set_value to avoid the stuttering issue
|
||||
await self._smartbridge.set_value(self.device_id, 0)
|
||||
await self.async_update()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
# Send appropriate directional command before stop to ensure it works correctly
|
||||
# Use tracked direction if moving, otherwise use position-based heuristic
|
||||
if self._movement_direction == ShadeMovementDirection.OPENING or (
|
||||
self._movement_direction in (ShadeMovementDirection.STOPPED, None)
|
||||
and self.current_cover_position >= 50
|
||||
):
|
||||
await self._smartbridge.raise_cover(self.device_id)
|
||||
else:
|
||||
await self._smartbridge.lower_cover(self.device_id)
|
||||
|
||||
await self._smartbridge.stop_cover(self.device_id)
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
await self._smartbridge.raise_cover(self.device_id)
|
||||
# Use set_value to avoid the stuttering issue
|
||||
await self._smartbridge.set_value(self.device_id, 100)
|
||||
await self.async_update()
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
@@ -21,6 +21,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
ACTION_MULTITAP,
|
||||
ACTION_PRESS,
|
||||
ACTION_RELEASE,
|
||||
ATTR_ACTION,
|
||||
@@ -39,7 +40,7 @@ def _reverse_dict(forward_dict: dict) -> dict:
|
||||
return {v: k for k, v in forward_dict.items()}
|
||||
|
||||
|
||||
SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_RELEASE]
|
||||
SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_MULTITAP, ACTION_RELEASE]
|
||||
|
||||
LUTRON_BUTTON_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||
{
|
||||
|
@@ -65,7 +65,11 @@ class LutronCasetaEntity(Entity):
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state)
|
||||
self._smartbridge.add_subscriber(self.device_id, self._handle_bridge_update)
|
||||
|
||||
def _handle_bridge_update(self) -> None:
|
||||
"""Handle updated data from the bridge."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _handle_none_serial(self, serial: str | int | None) -> str | int:
|
||||
"""Handle None serial returned by RA3 and QSX processors."""
|
||||
|
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pylutron_caseta"],
|
||||
"requirements": ["pylutron-caseta==0.24.0"],
|
||||
"requirements": ["pylutron-caseta==0.25.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_lutron._tcp.local.",
|
||||
|
@@ -296,24 +296,22 @@ class MatterClimate(MatterEntity, ClimateEntity):
|
||||
if running_state_value := self.get_matter_attribute_value(
|
||||
clusters.Thermostat.Attributes.ThermostatRunningState
|
||||
):
|
||||
match running_state_value:
|
||||
case (
|
||||
ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2
|
||||
):
|
||||
self._attr_hvac_action = HVACAction.HEATING
|
||||
case (
|
||||
ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2
|
||||
):
|
||||
self._attr_hvac_action = HVACAction.COOLING
|
||||
case (
|
||||
ThermostatRunningState.Fan
|
||||
| ThermostatRunningState.FanStage2
|
||||
| ThermostatRunningState.FanStage3
|
||||
):
|
||||
self._attr_hvac_action = HVACAction.FAN
|
||||
case _:
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
|
||||
if running_state_value & (
|
||||
ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2
|
||||
):
|
||||
self._attr_hvac_action = HVACAction.HEATING
|
||||
elif running_state_value & (
|
||||
ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2
|
||||
):
|
||||
self._attr_hvac_action = HVACAction.COOLING
|
||||
elif running_state_value & (
|
||||
ThermostatRunningState.Fan
|
||||
| ThermostatRunningState.FanStage2
|
||||
| ThermostatRunningState.FanStage3
|
||||
):
|
||||
self._attr_hvac_action = HVACAction.FAN
|
||||
else:
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
# update target temperature high/low
|
||||
supports_range = (
|
||||
self._attr_supported_features
|
||||
|
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Protocol
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -198,30 +197,6 @@ async def async_resolve_media(
|
||||
return await item.async_resolve()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_resolve_with_path(
|
||||
hass: HomeAssistant, media_content_id: str, target_media_player: str | None
|
||||
) -> PlayMedia:
|
||||
"""Get info to play media."""
|
||||
if DOMAIN not in hass.data:
|
||||
raise Unresolvable("Media Source not loaded")
|
||||
|
||||
try:
|
||||
item = _get_media_item(hass, media_content_id, target_media_player)
|
||||
except ValueError as err:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="resolve_media_failed",
|
||||
translation_placeholders={
|
||||
"media_content_id": str(media_content_id),
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
async with item.async_resolve_with_path() as media:
|
||||
yield media
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "media_source/browse_media",
|
||||
|
@@ -2,15 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import DOMAIN, MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX
|
||||
from .error import Unresolvable
|
||||
from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -105,12 +103,6 @@ class MediaSourceItem:
|
||||
assert self.domain is not None
|
||||
return self.hass.data[MEDIA_SOURCE_DATA][self.domain]
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_resolve_with_path(self) -> PlayMedia:
|
||||
"""Resolve to playable item with path."""
|
||||
async with self.async_media_source().async_resolve_with_path(self) as media:
|
||||
yield media
|
||||
|
||||
@classmethod
|
||||
def from_uri(
|
||||
cls, hass: HomeAssistant, uri: str, target_media_player: str | None
|
||||
@@ -140,23 +132,6 @@ class MediaSource:
|
||||
"""Resolve a media item to a playable item."""
|
||||
raise NotImplementedError
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_resolve_with_path(self, item: MediaSourceItem) -> PlayMedia:
|
||||
"""Resolve to playable item with path."""
|
||||
item = await self.async_resolve_media(item)
|
||||
|
||||
if item.path is None:
|
||||
raise Unresolvable(
|
||||
translation_domain=DOMAIN,
|
||||
# TODO translations
|
||||
translation_key="resolve_media_path_failed",
|
||||
translation_placeholders={
|
||||
"media_content_id": item.media_source_id,
|
||||
},
|
||||
)
|
||||
|
||||
yield item
|
||||
|
||||
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
|
||||
"""Browse media."""
|
||||
raise NotImplementedError
|
||||
|
@@ -338,7 +338,7 @@ STATE_PROGRAM_PHASE: dict[int, dict[int, str]] = {
|
||||
}
|
||||
|
||||
|
||||
class StateProgramType(MieleEnum):
|
||||
class StateProgramType(MieleEnum, missing_to_none=True):
|
||||
"""Defines program types."""
|
||||
|
||||
normal_operation_mode = 0
|
||||
@@ -346,10 +346,9 @@ class StateProgramType(MieleEnum):
|
||||
automatic_program = 2
|
||||
cleaning_care_program = 3
|
||||
maintenance_program = 4
|
||||
missing2none = -9999
|
||||
|
||||
|
||||
class StateDryingStep(MieleEnum):
|
||||
class StateDryingStep(MieleEnum, missing_to_none=True):
|
||||
"""Defines drying steps."""
|
||||
|
||||
extra_dry = 0
|
||||
@@ -360,7 +359,6 @@ class StateDryingStep(MieleEnum):
|
||||
hand_iron_2 = 5
|
||||
machine_iron = 6
|
||||
smoothing = 7
|
||||
missing2none = -9999
|
||||
|
||||
|
||||
WASHING_MACHINE_PROGRAM_ID: dict[int, str] = {
|
||||
@@ -1314,7 +1312,7 @@ STATE_PROGRAM_ID: dict[int, dict[int, str]] = {
|
||||
}
|
||||
|
||||
|
||||
class PlatePowerStep(MieleEnum):
|
||||
class PlatePowerStep(MieleEnum, missing_to_none=True):
|
||||
"""Plate power settings."""
|
||||
|
||||
plate_step_0 = 0
|
||||
@@ -1339,4 +1337,3 @@ class PlatePowerStep(MieleEnum):
|
||||
plate_step_18 = 18
|
||||
plate_step_boost = 117, 118, 218
|
||||
plate_step_boost_2 = 217
|
||||
missing2none = -9999
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user