mirror of
https://github.com/home-assistant/core.git
synced 2026-05-29 20:23:24 +02:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ddf5613ac3 | |||
| ec0bd39b7c | |||
| c6844a8223 | |||
| 5763829b4b | |||
| 7dfec6ef3d | |||
| efe55f247a | |||
| 85f3141776 | |||
| a175c7c4be | |||
| 03c83091ab | |||
| accebd7f38 | |||
| 9d3bb346e9 | |||
| d13721980e | |||
| ac6b5a5850 | |||
| 16dfa99673 | |||
| f51a02bbda | |||
| 6a51b21242 | |||
| 5eb502851c | |||
| ef20418c76 | |||
| 94ca34fd0c | |||
| 8634c22a53 | |||
| 5681ba40f1 | |||
| 8a9a1c5fed | |||
| c587e101af | |||
| 6eeeac46f3 | |||
| 86542b8ad0 | |||
| 7e07e7062c | |||
| d7c13fee27 | |||
| a0a44f7a25 | |||
| 2bba907013 | |||
| 0dcb8fc507 | |||
| 18e6f67650 | |||
| e5fad17e17 | |||
| 219b9cbcaa | |||
| 309b26f809 | |||
| e78cb0114d | |||
| 06a4247078 | |||
| 181e21dd2c | |||
| 31354d4129 | |||
| 57308d7760 | |||
| c07fed05df | |||
| 13ef737873 | |||
| 0a1510135c | |||
| 6f6b7888cd | |||
| b9173e36fb | |||
| a65ca9c86b | |||
| fc12d6fbb6 | |||
| 2a6b686254 | |||
| 4d841e4d84 | |||
| df08e9f311 | |||
| d53e40eea8 | |||
| 0b261b7198 | |||
| 3a9f32de25 | |||
| b5e54583c7 | |||
| 85ea7c1176 | |||
| 713f520bc8 | |||
| e4bb5a9395 | |||
| 936b2fe933 | |||
| c6c6f08885 | |||
| c621721851 | |||
| 5bb6b20641 |
@@ -24,6 +24,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ The following platforms have extra guidelines:
|
||||
## Entity platforms
|
||||
|
||||
- Ensure `async_added_to_hass()` and `async_will_remove_from_hass()` have symmetrical behavior. For example, if a subscription is created in `async_added_to_hass()`, it should be unsubscribed in `async_will_remove_from_hass()`. Also, if something is torn down in `async_will_remove_from_hass()`, it should be set up in `async_added_to_hass()`.
|
||||
- Entity base class (e.g. `SensorEntity`, `TrackerEntity`) provide a stable API for child classes to inherit from. Do not suggest redeclaring or duplicating attributes, properties, or methods the base class already provides, and do not add guards against the parent's behavior changing — rely on the base class instead.
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
|
||||
@@ -344,13 +344,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -380,7 +380,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -523,14 +523,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +543,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
# - github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
- uses: dessant/lock-threads@851cffe46851ddd2051ea7147ebdc995113241c3 # v6.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
"lg_tv_rs232",
|
||||
"webostv"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
||||
|
||||
_attr_event_types = [EVENT_TYPE]
|
||||
coordinator: AmazonDevicesCoordinator
|
||||
_last_seen_timestamp: int | None = None
|
||||
_last_seen_timestamp: int = 0 # January 1, 1970 at 12:00:00 AM
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
@@ -71,7 +71,8 @@ class AlexaVoiceEvent(AmazonEntity, EventEntity):
|
||||
)
|
||||
return
|
||||
|
||||
if vocal_record.timestamp == self._last_seen_timestamp:
|
||||
if vocal_record.timestamp <= self._last_seen_timestamp:
|
||||
# Discard old events that have already been processed
|
||||
return
|
||||
|
||||
self._last_seen_timestamp = vocal_record.timestamp
|
||||
|
||||
@@ -5,8 +5,12 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -49,6 +53,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
@@ -57,18 +62,39 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
snapshots_url: str | None = None
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
snapshots_url = None
|
||||
|
||||
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Analytics from a config entry."""
|
||||
snapshots_url = hass.data[_DATA_SNAPSHOTS_URL]
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
try:
|
||||
await analytics.load()
|
||||
except HassioNotReadyError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_ready",
|
||||
) from err
|
||||
|
||||
started = False
|
||||
|
||||
@@ -80,8 +106,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
async def start_schedule(hass: HomeAssistant) -> None:
|
||||
"""Start the send schedule once Home Assistant has started."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
@@ -89,12 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
async_at_started(hass, start_schedule)
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
@@ -109,7 +130,9 @@ def websocket_analytics(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return analytics preferences."""
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
|
||||
@@ -130,8 +153,10 @@ async def websocket_analytics_preferences(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
preferences = msg[ATTR_PREFERENCES]
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.async_schedule()
|
||||
|
||||
@@ -299,12 +299,8 @@ class Analytics:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable.
|
||||
# The caller is responsible for handling this and triggering a retry.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
@@ -349,10 +345,10 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
# Try to pull Supervisor information, but don't fail if some or all
|
||||
# of it is unavailable due to setup failures in the hassio integration.
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
operating_system_info = hassio.get_os_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Config flow for Analytics integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Analytics."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Analytics", data={})
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
@@ -14,5 +15,6 @@
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal"
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"supervisor_not_ready": {
|
||||
"message": "Supervisor was not ready during setup, will retry"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
|
||||
@@ -38,11 +38,13 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import AppleTvConfigEntry, AppleTVManager
|
||||
from .browse_media import build_app_list
|
||||
from .const import DOMAIN
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -126,7 +128,6 @@ class AppleTvMediaPlayer(
|
||||
@callback
|
||||
def async_device_connected(self, atv: AppleTV) -> None:
|
||||
"""Handle when connection is made to device."""
|
||||
# NB: Do not use _is_feature_available here as it only works when playing
|
||||
if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
|
||||
atv.push_updater.listener = self
|
||||
atv.push_updater.start()
|
||||
@@ -352,21 +353,41 @@ class AppleTvMediaPlayer(
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
media_type = MediaType.MUSIC
|
||||
|
||||
if self._is_feature_available(FeatureName.StreamFile) and (
|
||||
use_stream_file = self._is_feature_available(FeatureName.StreamFile) and (
|
||||
media_type == MediaType.MUSIC or await is_streamable(media_id)
|
||||
):
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
||||
):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Media streaming is not possible with current configuration for %s",
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
if use_stream_file:
|
||||
_LOGGER.debug("Streaming %s via RAOP", media_id)
|
||||
await self.atv.stream.stream_file(media_id)
|
||||
elif self._is_feature_available(FeatureName.PlayUrl) and (
|
||||
(parsed_url := URL(media_id)).is_absolute() and parsed_url.host
|
||||
):
|
||||
_LOGGER.debug("Playing %s via AirPlay", media_id)
|
||||
await self.atv.stream.play_url(media_id)
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="streaming_not_supported",
|
||||
)
|
||||
except exceptions.NotSupportedError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="streaming_not_supported",
|
||||
) from ex
|
||||
except (
|
||||
exceptions.BlockedStateError,
|
||||
exceptions.ConnectionLostError,
|
||||
exceptions.InvalidStateError,
|
||||
exceptions.OperationTimeoutError,
|
||||
exceptions.PlaybackError,
|
||||
exceptions.ProtocolError,
|
||||
) as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="stream_failed",
|
||||
) from ex
|
||||
|
||||
@property
|
||||
def media_image_hash(self) -> str | None:
|
||||
@@ -460,7 +481,7 @@ class AppleTvMediaPlayer(
|
||||
|
||||
def _is_feature_available(self, feature: FeatureName) -> bool:
|
||||
"""Return if a feature is available."""
|
||||
if self.atv and self._playing:
|
||||
if self.atv:
|
||||
return self.atv.features.in_state(FeatureState.Available, feature)
|
||||
return False
|
||||
|
||||
|
||||
@@ -81,6 +81,12 @@
|
||||
},
|
||||
"not_connected": {
|
||||
"message": "Apple TV is not connected"
|
||||
},
|
||||
"stream_failed": {
|
||||
"message": "Failed to stream media to the Apple TV"
|
||||
},
|
||||
"streaming_not_supported": {
|
||||
"message": "Streaming the requested media is not supported"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -27,6 +27,7 @@ from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
|
||||
from habluetooth import (
|
||||
BaseHaRemoteScanner,
|
||||
BaseHaScanner,
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScannerDevice,
|
||||
BluetoothScanningMode,
|
||||
HaBluetoothConnector,
|
||||
@@ -55,6 +56,7 @@ from . import passive_update_processor, websocket_api
|
||||
from .api import (
|
||||
_get_manager,
|
||||
async_address_present,
|
||||
async_address_reachability_diagnostics,
|
||||
async_ble_device_from_address,
|
||||
async_clear_address_from_match_history,
|
||||
async_clear_advertisement_history,
|
||||
@@ -108,12 +110,14 @@ __all__ = [
|
||||
"BluetoothCallback",
|
||||
"BluetoothCallbackMatcher",
|
||||
"BluetoothChange",
|
||||
"BluetoothReachabilityIntent",
|
||||
"BluetoothScannerDevice",
|
||||
"BluetoothScanningMode",
|
||||
"BluetoothServiceInfo",
|
||||
"BluetoothServiceInfoBleak",
|
||||
"HaBluetoothConnector",
|
||||
"async_address_present",
|
||||
"async_address_reachability_diagnostics",
|
||||
"async_ble_device_from_address",
|
||||
"async_clear_address_from_match_history",
|
||||
"async_clear_advertisement_history",
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, cast
|
||||
from bleak import BleakScanner
|
||||
from habluetooth import (
|
||||
BaseHaScanner,
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScannerDevice,
|
||||
BluetoothScanningMode,
|
||||
HaBleakScannerWrapper,
|
||||
@@ -108,6 +109,14 @@ def async_ble_device_from_address(
|
||||
return _get_manager(hass).async_ble_device_from_address(address, connectable)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_address_reachability_diagnostics(
|
||||
hass: HomeAssistant, address: str, intent: BluetoothReachabilityIntent
|
||||
) -> str:
|
||||
"""Return a human readable explanation of why an address may be unreachable."""
|
||||
return _get_manager(hass).async_address_reachability_diagnostics(address, intent)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_scanner_devices_by_address(
|
||||
hass: HomeAssistant, address: str, connectable: bool = True
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.7.9"
|
||||
"habluetooth==6.8.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Sony Bravia TV",
|
||||
"codeowners": ["@bieniu", "@Drafteed"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/braviatv",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -92,7 +92,7 @@ class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity):
|
||||
"""Representation of a Broadlink RF transmitter."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_translation_key = "rf_transmitter"
|
||||
|
||||
def __init__(self, device: BroadlinkDevice) -> None:
|
||||
"""Initialize the entity."""
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
"name": "IR emitter"
|
||||
}
|
||||
},
|
||||
"radio_frequency": {
|
||||
"rf_transmitter": {
|
||||
"name": "RF transmitter"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"day_of_week": {
|
||||
"name": "Day of week",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
|
||||
import caldav
|
||||
from caldav.lib.error import DAVError
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -26,7 +27,7 @@ async def async_get_calendars(
|
||||
for calendar in client.principal().calendars():
|
||||
try:
|
||||
supported_components = calendar.get_supported_components()
|
||||
except KeyError:
|
||||
except KeyError, DAVError:
|
||||
needs_warning.append((str(calendar.url), calendar.name, component))
|
||||
|
||||
if component in ASSUMED_COMPONENTS:
|
||||
|
||||
@@ -12,13 +12,19 @@ from homeassistant.const import (
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_EVENT,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.trigger import (
|
||||
TriggerActionType,
|
||||
TriggerInfo,
|
||||
# protected, but only used for legacy triggers
|
||||
_async_attach_trigger_cls,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -79,16 +85,18 @@ async def async_attach_trigger(
|
||||
event = zone.EVENT_ENTER
|
||||
else:
|
||||
event = zone.EVENT_LEAVE
|
||||
|
||||
zone_config = {
|
||||
CONF_PLATFORM: ZONE_DOMAIN,
|
||||
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
|
||||
CONF_ZONE: config[CONF_ZONE],
|
||||
CONF_EVENT: event,
|
||||
}
|
||||
zone_config = await zone.async_validate_trigger_config(hass, zone_config)
|
||||
return await zone.async_attach_trigger(
|
||||
hass, zone_config, action, trigger_info, platform_type="device"
|
||||
zone_config = await zone.LegacyZoneTrigger.async_validate_config(
|
||||
hass,
|
||||
{
|
||||
CONF_OPTIONS: {
|
||||
CONF_ENTITY_ID: [config[CONF_ENTITY_ID]],
|
||||
CONF_ZONE: config[CONF_ZONE],
|
||||
CONF_EVENT: event,
|
||||
}
|
||||
},
|
||||
)
|
||||
return await _async_attach_trigger_cls(
|
||||
hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
"""Fetch node data from the Duco box."""
|
||||
try:
|
||||
nodes = await self.client.async_get_nodes()
|
||||
lan_info = await self.client.async_get_lan_info()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -100,7 +99,18 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
# LAN info only backs the diagnostic RSSI sensor, so failures on this
|
||||
# supplemental endpoint, including connection failures, should not make
|
||||
# the primary node entities unavailable.
|
||||
rssi_wifi = self.data.rssi_wifi if self.data else None
|
||||
try:
|
||||
lan_info = await self.client.async_get_lan_info()
|
||||
except DucoError as err:
|
||||
_LOGGER.debug("Could not fetch Duco LAN info", exc_info=err)
|
||||
else:
|
||||
rssi_wifi = lan_info.rssi_wifi
|
||||
|
||||
return DucoData(
|
||||
nodes={node.node_id: node for node in nodes},
|
||||
rssi_wifi=lan_info.rssi_wifi,
|
||||
rssi_wifi=rssi_wifi,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather
|
||||
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
|
||||
errors = errors + 1
|
||||
_LOGGER.warning("Unable to retrieve Environment Canada weather")
|
||||
|
||||
radar_data = ECRadar(coordinates=(lat, lon))
|
||||
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
|
||||
radar_coordinator = ECDataUpdateCoordinator(
|
||||
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for the Environment Canada radar imagery."""
|
||||
|
||||
from env_canada import ECRadar
|
||||
from env_canada import ECMap
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
@@ -11,13 +11,20 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_OBSERVATION_TIME
|
||||
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
|
||||
|
||||
SERVICE_SET_RADAR_TYPE = "set_radar_type"
|
||||
SET_RADAR_TYPE_SCHEMA: VolDictType = {
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow"]),
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow", "Precipitation type"]),
|
||||
}
|
||||
|
||||
_RADAR_TYPE_TO_LAYER: dict[str, str] = {
|
||||
"Rain": "rain",
|
||||
"Snow": "snow",
|
||||
"Precipitation type": "precip_type",
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +45,13 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera):
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera):
|
||||
"""Implementation of an Environment Canada radar camera."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "radar"
|
||||
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None:
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECMap]) -> None:
|
||||
"""Initialize the camera."""
|
||||
super().__init__(coordinator)
|
||||
Camera.__init__(self)
|
||||
@@ -76,6 +83,13 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera
|
||||
|
||||
async def async_set_radar_type(self, radar_type: str) -> None:
|
||||
"""Set the type of radar to retrieve."""
|
||||
if radar_type == "Auto":
|
||||
# Choose rain for months April through October, snow otherwise
|
||||
layer = "rain" if dt_util.now().month in range(4, 11) else "snow"
|
||||
else:
|
||||
layer = _RADAR_TYPE_TO_LAYER[radar_type]
|
||||
|
||||
# Apply new layer and clear cache to force refresh
|
||||
self.radar_object.layer = layer
|
||||
self.radar_object.clear_cache()
|
||||
self.radar_object.precip_type = radar_type.lower()
|
||||
await self.radar_object.update()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -17,7 +17,7 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type ECConfigEntry = ConfigEntry[ECRuntimeData]
|
||||
type ECDataType = ECAirQuality | ECRadar | ECWeather
|
||||
type ECDataType = ECAirQuality | ECMap | ECWeather
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -25,7 +25,7 @@ class ECRuntimeData:
|
||||
"""Class to hold EC runtime data."""
|
||||
|
||||
aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECRadar]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECMap]
|
||||
weather_coordinator: ECDataUpdateCoordinator[ECWeather]
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ set_radar_type:
|
||||
fields:
|
||||
radar_type:
|
||||
required: true
|
||||
example: Snow
|
||||
example: Rain
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "Auto"
|
||||
- "Rain"
|
||||
- "Snow"
|
||||
- "Precipitation type"
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260527.0"]
|
||||
"requirements": ["home-assistant-frontend==20260527.1"]
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = {
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.RECEIVER): TYPE_RECEIVER,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.SPEAKER): TYPE_SPEAKER,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.TV): TYPE_TV,
|
||||
(media_player.DOMAIN, media_player.MediaPlayerDeviceClass.PROJECTOR): TYPE_TV,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.AQI): TYPE_SENSOR,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.HUMIDITY): TYPE_SENSOR,
|
||||
(sensor.DOMAIN, sensor.SensorDeviceClass.TEMPERATURE): TYPE_SENSOR,
|
||||
|
||||
@@ -2728,7 +2728,11 @@ class ChannelTrait(_Trait):
|
||||
if (
|
||||
domain == media_player.DOMAIN
|
||||
and (features & MediaPlayerEntityFeature.PLAY_MEDIA)
|
||||
and device_class == media_player.MediaPlayerDeviceClass.TV
|
||||
and device_class
|
||||
in (
|
||||
media_player.MediaPlayerDeviceClass.TV,
|
||||
media_player.MediaPlayerDeviceClass.PROJECTOR,
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
|
||||
@@ -202,7 +202,10 @@ def get_accessory( # noqa: C901
|
||||
|
||||
if device_class == MediaPlayerDeviceClass.RECEIVER:
|
||||
a_type = "ReceiverMediaPlayer"
|
||||
elif device_class == MediaPlayerDeviceClass.TV:
|
||||
elif device_class in (
|
||||
MediaPlayerDeviceClass.TV,
|
||||
MediaPlayerDeviceClass.PROJECTOR,
|
||||
):
|
||||
a_type = "TelevisionMediaPlayer"
|
||||
elif validate_media_player_features(state, feature_list):
|
||||
a_type = "MediaPlayer"
|
||||
|
||||
@@ -695,7 +695,11 @@ def state_needs_accessory_mode(state: State) -> bool:
|
||||
return (
|
||||
state.domain == MEDIA_PLAYER_DOMAIN
|
||||
and state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
in (MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.RECEIVER)
|
||||
in (
|
||||
MediaPlayerDeviceClass.TV,
|
||||
MediaPlayerDeviceClass.RECEIVER,
|
||||
MediaPlayerDeviceClass.PROJECTOR,
|
||||
)
|
||||
) or (
|
||||
state.domain == REMOTE_DOMAIN
|
||||
and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"light": {
|
||||
"hue_grouped_light": {
|
||||
"default": "mdi:lightbulb-group",
|
||||
"state": {
|
||||
"off": "mdi:lightbulb-group-off"
|
||||
}
|
||||
},
|
||||
"hue_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
|
||||
@@ -85,7 +85,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
|
||||
entity_description = LightEntityDescription(
|
||||
key="hue_grouped_light",
|
||||
icon="mdi:lightbulb-group",
|
||||
has_entity_name=True,
|
||||
name=None,
|
||||
)
|
||||
|
||||
@@ -178,17 +178,21 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
@property
|
||||
def max_color_temp_mireds(self) -> int:
|
||||
"""Return the warmest color_temp in mireds that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_maximum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
if (color_temp := self.resource.color_temperature) and (
|
||||
mirek_max := color_temp.mirek_schema.mirek_maximum
|
||||
):
|
||||
return mirek_max
|
||||
# return a fallback value if the light doesn't provide valid limits
|
||||
return FALLBACK_MAX_MIREDS
|
||||
|
||||
@property
|
||||
def min_color_temp_mireds(self) -> int:
|
||||
"""Return the coldest color_temp in mireds that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_minimum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
if (color_temp := self.resource.color_temperature) and (
|
||||
mirek_min := color_temp.mirek_schema.mirek_minimum
|
||||
):
|
||||
return mirek_min
|
||||
# return a fallback value if the light doesn't provide valid limits
|
||||
return FALLBACK_MIN_MIREDS
|
||||
|
||||
@property
|
||||
|
||||
@@ -67,7 +67,7 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
|
||||
await coordinator.api.albums.async_add_assets_to_album(
|
||||
target_album, [upload_result.asset_id]
|
||||
)
|
||||
except ImmichError as ex:
|
||||
except (ImmichError, FileNotFoundError) as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="upload_failed",
|
||||
|
||||
@@ -94,7 +94,7 @@ async def async_setup_entry(
|
||||
async_add_entities(device.zones.values())
|
||||
|
||||
# create any components not yet created
|
||||
for controller in disco.pi_disco.controllers.values():
|
||||
for controller in (await disco.pi_disco.fetch_controllers()).values():
|
||||
init_controller(controller)
|
||||
|
||||
# connect to register any further components
|
||||
|
||||
@@ -29,12 +29,13 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
async with asyncio.timeout(TIMEOUT_DISCOVERY):
|
||||
await controller_ready.wait()
|
||||
|
||||
if not disco.pi_disco.controllers:
|
||||
controllers = await disco.pi_disco.fetch_controllers()
|
||||
if not controllers:
|
||||
await async_stop_discovery_service(hass)
|
||||
_LOGGER.debug("No controllers found")
|
||||
return False
|
||||
|
||||
_LOGGER.debug("Controllers %s", disco.pi_disco.controllers)
|
||||
_LOGGER.debug("Controllers %s", controllers)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -105,10 +105,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
|
||||
except ThinQAPIException as exc:
|
||||
if on_fail_method:
|
||||
on_fail_method()
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
exc.message, translation_domain=DOMAIN, translation_key=exc.code
|
||||
) from exc
|
||||
raise ServiceValidationError(exc.message) from exc
|
||||
except ValueError as exc:
|
||||
if on_fail_method:
|
||||
on_fail_method()
|
||||
|
||||
@@ -108,5 +108,7 @@ def create_matter_ble_proxy(hass: HomeAssistant, ws_url: str) -> MatterBleProxy:
|
||||
ws_url=ws_url,
|
||||
scan_source=HaBluetoothScanSource(hass),
|
||||
device_resolver=HaBluetoothDeviceResolver(hass),
|
||||
task_factory=hass.async_create_task,
|
||||
task_factory=lambda coro: hass.async_create_background_task(
|
||||
coro, name="matter_ble_proxy"
|
||||
),
|
||||
)
|
||||
|
||||
@@ -155,6 +155,7 @@ class MediaPlayerDeviceClass(StrEnum):
|
||||
TV = "tv"
|
||||
SPEAKER = "speaker"
|
||||
RECEIVER = "receiver"
|
||||
PROJECTOR = "projector"
|
||||
|
||||
|
||||
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass))
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
"playing": "mdi:cast-connected"
|
||||
}
|
||||
},
|
||||
"projector": {
|
||||
"default": "mdi:projector",
|
||||
"state": {
|
||||
"off": "mdi:projector-off"
|
||||
}
|
||||
},
|
||||
"receiver": {
|
||||
"default": "mdi:audio-video",
|
||||
"state": {
|
||||
|
||||
@@ -261,6 +261,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"projector": {
|
||||
"name": "Projector"
|
||||
},
|
||||
"receiver": {
|
||||
"name": "Receiver"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DISCOVERY,
|
||||
CONF_PLATFORM,
|
||||
CONF_PORT,
|
||||
CONF_PROTOCOL,
|
||||
SERVICE_RELOAD,
|
||||
)
|
||||
@@ -50,6 +51,7 @@ from .client import (
|
||||
async_subscribe_internal,
|
||||
publish,
|
||||
subscribe,
|
||||
try_connection,
|
||||
)
|
||||
from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA
|
||||
from .config_integration import CONFIG_SCHEMA_BASE
|
||||
@@ -79,14 +81,15 @@ from .const import (
|
||||
CONFIG_ENTRY_VERSION,
|
||||
DEFAULT_DISCOVERY,
|
||||
DEFAULT_ENCODING,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_PREFIX,
|
||||
DEFAULT_PROTOCOL,
|
||||
DEFAULT_QOS,
|
||||
DEFAULT_RETAIN,
|
||||
DOMAIN,
|
||||
ENTITY_PLATFORMS,
|
||||
ENTRY_OPTION_FIELDS,
|
||||
MQTT_CONNECTION_STATE,
|
||||
PROTOCOL_5,
|
||||
PROTOCOL_311,
|
||||
TEMPLATE_ERRORS,
|
||||
Platform,
|
||||
@@ -496,25 +499,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Load a config entry."""
|
||||
mqtt_data: MqttData
|
||||
|
||||
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL:
|
||||
broker: str = entry.data[CONF_BROKER]
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"protocol_5_migration",
|
||||
issue_domain=DOMAIN,
|
||||
is_fixable=True,
|
||||
breaks_in_ha_version="2027.1.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol",
|
||||
data={
|
||||
"entry_id": entry.entry_id,
|
||||
"broker": broker,
|
||||
"protocol": protocol,
|
||||
},
|
||||
translation_placeholders={"broker": broker, "protocol": protocol},
|
||||
translation_key="protocol_5_migration",
|
||||
)
|
||||
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != PROTOCOL_5:
|
||||
# Automatically migrate the broker protocol to v5 if possible
|
||||
# Can be removed with HA Core 2027.1
|
||||
new_entry_data = entry.data.copy()
|
||||
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
|
||||
# Try the connection with protocol version 5
|
||||
# And update the protocol if successful
|
||||
if await hass.async_add_executor_job(
|
||||
try_connection,
|
||||
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
|
||||
):
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=new_entry_data,
|
||||
)
|
||||
ir.async_delete_issue(hass, DOMAIN, "protocol_5_migration")
|
||||
_LOGGER.info(
|
||||
"The MQTT protocol version was successfully updated to version 5"
|
||||
)
|
||||
else:
|
||||
broker: str = entry.data[CONF_BROKER]
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"protocol_5_migration",
|
||||
issue_domain=DOMAIN,
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2027.1.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/mqtt/"
|
||||
"#mqtt-protocol",
|
||||
data={
|
||||
"entry_id": entry.entry_id,
|
||||
"broker": broker,
|
||||
"protocol": protocol,
|
||||
},
|
||||
translation_placeholders={"broker": broker, "protocol": protocol},
|
||||
translation_key="protocol_5_migration",
|
||||
)
|
||||
|
||||
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
|
||||
"""Set up the MQTT client."""
|
||||
|
||||
@@ -9,6 +9,7 @@ from functools import lru_cache, partial
|
||||
from itertools import chain, groupby
|
||||
import logging
|
||||
from operator import attrgetter
|
||||
import queue
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
@@ -92,6 +93,8 @@ from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabl
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MQTT_TIMEOUT = 5
|
||||
|
||||
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
|
||||
PREFERRED_BUFFER_SIZE = 8 * 1024 * 1024 # Set receive buffer size to 8MiB
|
||||
|
||||
@@ -433,6 +436,40 @@ class MqttClientSetup:
|
||||
return self._client
|
||||
|
||||
|
||||
def try_connection(
|
||||
user_input: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Test if we can connect to an MQTT broker."""
|
||||
mqtt_client_setup = MqttClientSetup(user_input)
|
||||
mqtt_client_setup.setup()
|
||||
client = mqtt_client_setup.client
|
||||
|
||||
result: queue.Queue[bool] = queue.Queue(maxsize=1)
|
||||
|
||||
def on_connect(
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
_connect_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""Handle connection result."""
|
||||
result.put(not reason_code.is_failure)
|
||||
|
||||
client.on_connect = on_connect
|
||||
|
||||
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
return result.get(timeout=MQTT_TIMEOUT)
|
||||
except queue.Empty:
|
||||
return False
|
||||
finally:
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
class MQTT:
|
||||
"""Home Assistant MQTT client."""
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -22,7 +21,6 @@ from cryptography.hazmat.primitives.serialization import (
|
||||
load_pem_private_key,
|
||||
)
|
||||
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
|
||||
import paho.mqtt.client as mqtt
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
@@ -143,7 +141,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from .addon import get_addon_manager
|
||||
from .client import MqttClientSetup
|
||||
from .client import try_connection
|
||||
from .const import (
|
||||
ALARM_CONTROL_PANEL_SUPPORTED_FEATURES,
|
||||
ATTR_PAYLOAD,
|
||||
@@ -444,8 +442,6 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5
|
||||
|
||||
CONF_CLIENT_KEY_PASSWORD = "client_key_password"
|
||||
|
||||
MQTT_TIMEOUT = 5
|
||||
|
||||
ADVANCED_OPTIONS = "advanced_options"
|
||||
SET_CA_CERT = "set_ca_cert"
|
||||
SET_CLIENT_CERT = "set_client_cert"
|
||||
@@ -5457,7 +5453,6 @@ async def async_get_broker_settings(
|
||||
or current_client_certificate
|
||||
or current_client_key
|
||||
or current_tls_insecure
|
||||
or current_protocol != DEFAULT_PROTOCOL
|
||||
or current_config.get(SET_CA_CERT, "off") != "off"
|
||||
or current_config.get(SET_CLIENT_CERT)
|
||||
or current_transport == TRANSPORT_WEBSOCKETS
|
||||
@@ -5466,6 +5461,12 @@ async def async_get_broker_settings(
|
||||
# Build form
|
||||
fields[vol.Required(CONF_BROKER, default=current_broker)] = TEXT_SELECTOR
|
||||
fields[vol.Required(CONF_PORT, default=current_port)] = PORT_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_PROTOCOL,
|
||||
description={"suggested_value": current_protocol},
|
||||
)
|
||||
] = PROTOCOL_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_USERNAME,
|
||||
@@ -5556,12 +5557,6 @@ async def async_get_broker_settings(
|
||||
description={"suggested_value": current_tls_insecure},
|
||||
)
|
||||
] = BOOLEAN_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_PROTOCOL,
|
||||
description={"suggested_value": current_protocol},
|
||||
)
|
||||
] = PROTOCOL_SELECTOR
|
||||
fields[
|
||||
vol.Optional(
|
||||
CONF_TRANSPORT,
|
||||
@@ -5582,40 +5577,6 @@ async def async_get_broker_settings(
|
||||
return False
|
||||
|
||||
|
||||
def try_connection(
|
||||
user_input: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Test if we can connect to an MQTT broker."""
|
||||
mqtt_client_setup = MqttClientSetup(user_input)
|
||||
mqtt_client_setup.setup()
|
||||
client = mqtt_client_setup.client
|
||||
|
||||
result: queue.Queue[bool] = queue.Queue(maxsize=1)
|
||||
|
||||
def on_connect(
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
_connect_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""Handle connection result."""
|
||||
result.put(not reason_code.is_failure)
|
||||
|
||||
client.on_connect = on_connect
|
||||
|
||||
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
return result.get(timeout=MQTT_TIMEOUT)
|
||||
except queue.Empty:
|
||||
return False
|
||||
finally:
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
def check_certicate_chain() -> str | None:
|
||||
"""Check the MQTT certificates."""
|
||||
if client_certificate := get_file_path(CONF_CLIENT_CERT):
|
||||
|
||||
@@ -5,12 +5,10 @@ from typing import TYPE_CHECKING
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .config_flow import try_connection
|
||||
from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5
|
||||
from .const import DOMAIN
|
||||
|
||||
URL_MQTT_BROKER_CONFIGURATION = (
|
||||
"https://www.home-assistant.io/integrations/mqtt/#broker-configuration"
|
||||
@@ -55,55 +53,6 @@ class MQTTDeviceEntryMigration(RepairsFlow):
|
||||
)
|
||||
|
||||
|
||||
class MQTTProtocolV5Migration(RepairsFlow):
|
||||
"""Handler to migrate to MQTT protocol version 5."""
|
||||
|
||||
def __init__(self, entry_id: str, broker: str, protocol: str) -> None:
|
||||
"""Initialize the flow."""
|
||||
self.entry_id = entry_id
|
||||
self.broker = broker
|
||||
self.protocol = protocol
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
"""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
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
entry = self.hass.config_entries.async_get_entry(self.entry_id)
|
||||
if TYPE_CHECKING:
|
||||
assert entry is not None
|
||||
new_entry_data = entry.data.copy()
|
||||
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
|
||||
# Try the connection with protocol version 5
|
||||
if await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
|
||||
):
|
||||
self.hass.config_entries.async_update_entry(entry, data=new_entry_data)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
return self.async_abort(
|
||||
reason="mqtt_broker_migration_to_v5_failed",
|
||||
description_placeholders={
|
||||
"broker": self.broker,
|
||||
"protocol": self.protocol,
|
||||
"url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders={"broker": self.broker, "protocol": self.protocol},
|
||||
)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
@@ -113,10 +62,6 @@ async def async_create_fix_flow(
|
||||
if TYPE_CHECKING:
|
||||
assert data is not None
|
||||
entry_id: str = data["entry_id"] # type: ignore[assignment]
|
||||
if issue_id == "protocol_5_migration":
|
||||
broker: str = data["broker"] # type: ignore[assignment]
|
||||
protocol: str = data["protocol"] # type: ignore[assignment]
|
||||
return MQTTProtocolV5Migration(entry_id, broker, protocol)
|
||||
subentry_id: str = data["subentry_id"] # type: ignore[assignment]
|
||||
name: str = data["name"] # type: ignore[assignment]
|
||||
return MQTTDeviceEntryMigration(
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"keepalive": "A value less than 90 seconds is advised.",
|
||||
"password": "The password to log in to your MQTT broker.",
|
||||
"port": "The port your MQTT broker listens to. For example 1883.",
|
||||
"protocol": "The MQTT protocol your broker operates at. For example 3.1.1.",
|
||||
"protocol": "The MQTT protocol version that is used. Note that Home Assistant will silently change to version 5 if your broker supports it.",
|
||||
"set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT broker's certificate.",
|
||||
"set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.",
|
||||
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
|
||||
@@ -1134,18 +1134,8 @@
|
||||
"title": "Invalid config found for MQTT {domain} item"
|
||||
},
|
||||
"protocol_5_migration": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue."
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Home Assistant needs to migrate to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will attempt to migrate your MQTT broker configuration to use protocol version 5 to fix this issue. If the broker cannot be reached using MQTT protocol version 5, for example because it does not support it, the migration will be aborted.",
|
||||
"title": "MQTT protocol change required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Deprecated MQTT protocol {protocol} in use"
|
||||
"description": "The automatic migration to MQTT protocol version 5 failed. The currently configured protocol version for MQTT broker {broker} is {protocol}, but this protocol version is deprecated, and support for it will be removed.\n\nMake sure your broker supports protocol version 5. Update your MQTT broker's connection settings, and restart Home Assistant to fix this issue.",
|
||||
"title": "MQTT protocol migration failed"
|
||||
},
|
||||
"subentry_migration_discovery": {
|
||||
"fix_flow": {
|
||||
|
||||
+1
@@ -57,6 +57,7 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
|
||||
OverkizCommandParam.STANDBY: HVACMode.OFF, # main command
|
||||
OverkizCommandParam.AUTO: HVACMode.AUTO,
|
||||
OverkizCommandParam.EXTERNAL: HVACMode.AUTO,
|
||||
OverkizCommandParam.PROG: HVACMode.AUTO,
|
||||
OverkizCommandParam.INTERNAL: HVACMode.AUTO, # main command
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ from . import OVHcloudAIEndpointsConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import OVHcloudAIEndpointsEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Diagnostics support for OVHcloud AI Endpoints."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from openai import __title__, __version__
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PROMPT
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import OVHcloudAIEndpointsConfigEntry
|
||||
|
||||
|
||||
TO_REDACT = {CONF_API_KEY, CONF_PROMPT}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: OVHcloudAIEndpointsConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"client": f"{__title__}=={__version__}",
|
||||
"title": entry.title,
|
||||
"entry_id": entry.entry_id,
|
||||
"entry_version": f"{entry.version}.{entry.minor_version}",
|
||||
"state": entry.state.value,
|
||||
"data": async_redact_data(entry.data, TO_REDACT),
|
||||
"options": async_redact_data(entry.options, TO_REDACT),
|
||||
"subentries": {
|
||||
subentry.subentry_id: {
|
||||
"title": subentry.title,
|
||||
"subentry_type": subentry.subentry_type,
|
||||
"data": async_redact_data(subentry.data, TO_REDACT),
|
||||
}
|
||||
for subentry in entry.subentries.values()
|
||||
},
|
||||
"entities": {
|
||||
entity_entry.entity_id: entity_entry.extended_dict
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
er.async_get(hass), entry.entry_id
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ovhcloud_ai_endpoints",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["openai==2.21.0"]
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
@@ -43,13 +45,13 @@ rules:
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: the integration only integrates stateless entities
|
||||
parallel-updates: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Service can't be discovered
|
||||
|
||||
@@ -73,20 +73,26 @@ async def _get_endpoint_id(
|
||||
device_reg = dr.async_get(call.hass)
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
device = device_reg.async_get(device_id)
|
||||
assert device
|
||||
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
endpoint_data = None
|
||||
for data in coordinator.data.values():
|
||||
if (
|
||||
DOMAIN,
|
||||
f"{config_entry.entry_id}_{data.endpoint.id}",
|
||||
) in device.identifiers:
|
||||
endpoint_data = data
|
||||
break
|
||||
return data.endpoint.id
|
||||
|
||||
assert endpoint_data
|
||||
return endpoint_data.endpoint.id
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
|
||||
async def _get_container_and_endpoint_ids(
|
||||
@@ -95,6 +101,7 @@ async def _get_container_and_endpoint_ids(
|
||||
"""Get config entry, endpoint ID and container ID from the container device ID."""
|
||||
device_reg = dr.async_get(call.hass)
|
||||
device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
|
||||
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_LOCALE, DOMAIN, PLATFORMS
|
||||
from .const import DOMAIN, PLATFORMS, RenaultConfigurationKeys
|
||||
from .renault_hub import RenaultHub
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -28,7 +28,7 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: RenaultConfigEntry
|
||||
) -> bool:
|
||||
"""Load a config entry."""
|
||||
renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE])
|
||||
renault_hub = RenaultHub(hass, config_entry.data[RenaultConfigurationKeys.LOCALE])
|
||||
try:
|
||||
await renault_hub.async_initialise(config_entry)
|
||||
except NotAuthenticatedException as exc:
|
||||
|
||||
@@ -14,21 +14,22 @@ from homeassistant.config_entries import (
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, CONF_LOGIN_TOKEN, DOMAIN
|
||||
from .const import DOMAIN, RenaultConfigurationKeys
|
||||
from .renault_hub import RenaultHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()),
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(RenaultConfigurationKeys.LOCALE.value): vol.In(
|
||||
AVAILABLE_LOCALES.keys()
|
||||
),
|
||||
vol.Required(RenaultConfigurationKeys.USERNAME.value): str,
|
||||
vol.Required(RenaultConfigurationKeys.PASSWORD.value): str,
|
||||
}
|
||||
)
|
||||
REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
|
||||
REAUTH_SCHEMA = vol.Schema({vol.Required(RenaultConfigurationKeys.PASSWORD.value): str})
|
||||
|
||||
|
||||
class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -50,13 +51,14 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
suggested_values: Mapping[str, Any] | None = None
|
||||
if user_input:
|
||||
locale = user_input[CONF_LOCALE]
|
||||
locale = user_input[RenaultConfigurationKeys.LOCALE]
|
||||
self.renault_config.update(user_input)
|
||||
self.renault_config.update(AVAILABLE_LOCALES[locale])
|
||||
self.renault_hub = RenaultHub(self.hass, locale)
|
||||
try:
|
||||
login_success = await self.renault_hub.attempt_login(
|
||||
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
user_input[RenaultConfigurationKeys.USERNAME],
|
||||
user_input[RenaultConfigurationKeys.PASSWORD],
|
||||
)
|
||||
except aiohttp.ClientConnectionError, GigyaException:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -67,7 +69,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if login_success:
|
||||
if TYPE_CHECKING:
|
||||
assert self.renault_hub.login_token
|
||||
self.renault_config[CONF_LOGIN_TOKEN] = self.renault_hub.login_token
|
||||
self.renault_config[RenaultConfigurationKeys.LOGIN_TOKEN] = (
|
||||
self.renault_hub.login_token
|
||||
)
|
||||
return await self.async_step_kamereon()
|
||||
errors["base"] = "invalid_credentials"
|
||||
suggested_values = user_input
|
||||
@@ -87,7 +91,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Select Kamereon account."""
|
||||
if user_input:
|
||||
await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID])
|
||||
await self.async_set_unique_id(
|
||||
user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
|
||||
)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
self.renault_config.update(user_input)
|
||||
@@ -100,7 +106,8 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self.renault_config.update(user_input)
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config
|
||||
title=user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID],
|
||||
data=self.renault_config,
|
||||
)
|
||||
|
||||
accounts = await self.renault_hub.get_account_ids()
|
||||
@@ -108,13 +115,17 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="kamereon_no_account")
|
||||
if len(accounts) == 1:
|
||||
return await self.async_step_kamereon(
|
||||
user_input={CONF_KAMEREON_ACCOUNT_ID: accounts[0]}
|
||||
user_input={RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: accounts[0]}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="kamereon",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)}
|
||||
{
|
||||
vol.Required(
|
||||
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID.value
|
||||
): vol.In(accounts)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -132,17 +143,22 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if user_input:
|
||||
# Check credentials
|
||||
self.renault_hub = RenaultHub(self.hass, reauth_entry.data[CONF_LOCALE])
|
||||
self.renault_hub = RenaultHub(
|
||||
self.hass, reauth_entry.data[RenaultConfigurationKeys.LOCALE]
|
||||
)
|
||||
if await self.renault_hub.attempt_login(
|
||||
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
reauth_entry.data[RenaultConfigurationKeys.USERNAME],
|
||||
user_input[RenaultConfigurationKeys.PASSWORD],
|
||||
):
|
||||
if TYPE_CHECKING:
|
||||
assert self.renault_hub.login_token
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_LOGIN_TOKEN: self.renault_hub.login_token,
|
||||
RenaultConfigurationKeys.PASSWORD: user_input[
|
||||
RenaultConfigurationKeys.PASSWORD
|
||||
],
|
||||
RenaultConfigurationKeys.LOGIN_TOKEN: self.renault_hub.login_token,
|
||||
},
|
||||
)
|
||||
errors = {"base": "invalid_credentials"}
|
||||
@@ -151,7 +167,11 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
step_id="reauth_confirm",
|
||||
data_schema=REAUTH_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
|
||||
description_placeholders={
|
||||
RenaultConfigurationKeys.USERNAME: reauth_entry.data[
|
||||
RenaultConfigurationKeys.USERNAME
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"""Constants for the Renault component."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "renault"
|
||||
|
||||
CONF_LOCALE = "locale"
|
||||
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
|
||||
CONF_LOGIN_TOKEN = "login_token"
|
||||
|
||||
class RenaultConfigurationKeys(StrEnum):
|
||||
"""Configuration keys."""
|
||||
|
||||
LOCALE = "locale"
|
||||
KAMEREON_ACCOUNT_ID = "kamereon_account_id"
|
||||
LOGIN_TOKEN = "login_token"
|
||||
USERNAME = "username"
|
||||
PASSWORD = "password"
|
||||
|
||||
|
||||
# normal number of allowed calls per hour to the API
|
||||
# for a single car and the 7 coordinator, it is a scan every 7mn
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from . import RenaultConfigEntry
|
||||
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOGIN_TOKEN
|
||||
from .const import RenaultConfigurationKeys
|
||||
from .renault_vehicle import RenaultVehicleProxy
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_KAMEREON_ACCOUNT_ID,
|
||||
CONF_LOGIN_TOKEN,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID,
|
||||
RenaultConfigurationKeys.LOGIN_TOKEN,
|
||||
RenaultConfigurationKeys.PASSWORD,
|
||||
RenaultConfigurationKeys.USERNAME,
|
||||
"radioCode",
|
||||
"registrationNumber",
|
||||
"vin",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from time import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from renault_api.exceptions import NotAuthenticatedException
|
||||
@@ -17,27 +18,22 @@ from homeassistant.const import (
|
||||
ATTR_MODEL,
|
||||
ATTR_MODEL_ID,
|
||||
ATTR_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import RenaultConfigEntry
|
||||
|
||||
from time import time
|
||||
|
||||
from .const import (
|
||||
CONF_KAMEREON_ACCOUNT_ID,
|
||||
CONF_LOGIN_TOKEN,
|
||||
COOLING_UPDATES_SECONDS,
|
||||
MAX_CALLS_PER_HOURS,
|
||||
RenaultConfigurationKeys,
|
||||
)
|
||||
from .renault_vehicle import COORDINATORS, RenaultVehicleProxy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import RenaultConfigEntry
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -106,20 +102,26 @@ class RenaultHub:
|
||||
async def async_initialise(self, config_entry: RenaultConfigEntry) -> None:
|
||||
"""Set up proxy."""
|
||||
# Reuse the stored login token, or fall back to a password login.
|
||||
if login_token := config_entry.data.get(CONF_LOGIN_TOKEN):
|
||||
if login_token := config_entry.data.get(RenaultConfigurationKeys.LOGIN_TOKEN):
|
||||
self._client.session.set_login_token(login_token)
|
||||
elif await self.attempt_login(
|
||||
config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]
|
||||
config_entry.data[RenaultConfigurationKeys.USERNAME],
|
||||
config_entry.data[RenaultConfigurationKeys.PASSWORD],
|
||||
):
|
||||
# Persist the login token so the next setup can skip the password.
|
||||
self._hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={**config_entry.data, CONF_LOGIN_TOKEN: self.login_token},
|
||||
data={
|
||||
**config_entry.data,
|
||||
RenaultConfigurationKeys.LOGIN_TOKEN: self.login_token,
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise NotAuthenticatedException
|
||||
|
||||
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID]
|
||||
account_id: str = config_entry.data[
|
||||
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID
|
||||
]
|
||||
|
||||
self._account = await self._client.get_api_account(account_id)
|
||||
vehicle_links = await _get_filtered_vehicles(self._account)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Support for Renault services."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
@@ -19,24 +19,30 @@ if TYPE_CHECKING:
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_SCHEDULES = "schedules"
|
||||
ATTR_VEHICLE = "vehicle"
|
||||
ATTR_WHEN = "when"
|
||||
|
||||
class RenaultServiceArgument(StrEnum):
|
||||
"""Service argument names."""
|
||||
|
||||
SCHEDULES = "schedules"
|
||||
TEMPERATURE = "temperature"
|
||||
VEHICLE = "vehicle"
|
||||
WHEN = "when"
|
||||
|
||||
|
||||
SERVICE_VEHICLE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_VEHICLE): cv.string,
|
||||
vol.Required(RenaultServiceArgument.VEHICLE.value): cv.string,
|
||||
}
|
||||
)
|
||||
SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_TEMPERATURE): cv.positive_float,
|
||||
vol.Optional(ATTR_WHEN): cv.datetime,
|
||||
vol.Required(RenaultServiceArgument.TEMPERATURE.value): cv.positive_float,
|
||||
vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime,
|
||||
}
|
||||
)
|
||||
SERVICE_CHARGE_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(ATTR_WHEN): cv.datetime,
|
||||
vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime,
|
||||
}
|
||||
)
|
||||
SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema(
|
||||
@@ -62,7 +68,7 @@ SERVICE_CHARGE_SET_SCHEDULE_SCHEMA = vol.Schema(
|
||||
)
|
||||
SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_SCHEDULES): vol.All(
|
||||
vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All(
|
||||
cv.ensure_list, [SERVICE_CHARGE_SET_SCHEDULE_SCHEMA]
|
||||
),
|
||||
}
|
||||
@@ -89,7 +95,7 @@ SERVICE_AC_SET_SCHEDULE_SCHEMA = vol.Schema(
|
||||
)
|
||||
SERVICE_AC_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_SCHEDULES): vol.All(
|
||||
vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All(
|
||||
cv.ensure_list, [SERVICE_AC_SET_SCHEDULE_SCHEMA]
|
||||
),
|
||||
}
|
||||
@@ -107,8 +113,8 @@ async def ac_cancel(service_call: ServiceCall) -> None:
|
||||
|
||||
async def ac_start(service_call: ServiceCall) -> None:
|
||||
"""Start A/C."""
|
||||
temperature: float = service_call.data[ATTR_TEMPERATURE]
|
||||
when: datetime | None = service_call.data.get(ATTR_WHEN)
|
||||
temperature: float = service_call.data[RenaultServiceArgument.TEMPERATURE]
|
||||
when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN)
|
||||
proxy = get_vehicle_proxy(service_call)
|
||||
|
||||
LOGGER.debug("A/C start attempt: %s / %s", temperature, when)
|
||||
@@ -118,7 +124,7 @@ async def ac_start(service_call: ServiceCall) -> None:
|
||||
|
||||
async def charge_start(service_call: ServiceCall) -> None:
|
||||
"""Start Charging with optional delay."""
|
||||
when: datetime | None = service_call.data.get(ATTR_WHEN)
|
||||
when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN)
|
||||
proxy = get_vehicle_proxy(service_call)
|
||||
|
||||
LOGGER.debug("Charge start attempt, when: %s", when)
|
||||
@@ -128,7 +134,9 @@ async def charge_start(service_call: ServiceCall) -> None:
|
||||
|
||||
async def charge_set_schedules(service_call: ServiceCall) -> None:
|
||||
"""Set charge schedules."""
|
||||
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
|
||||
schedules: list[dict[str, Any]] = service_call.data[
|
||||
RenaultServiceArgument.SCHEDULES
|
||||
]
|
||||
proxy = get_vehicle_proxy(service_call)
|
||||
charge_schedules = await proxy.get_charging_settings()
|
||||
for schedule in schedules:
|
||||
@@ -147,7 +155,9 @@ async def charge_set_schedules(service_call: ServiceCall) -> None:
|
||||
|
||||
async def ac_set_schedules(service_call: ServiceCall) -> None:
|
||||
"""Set A/C schedules."""
|
||||
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
|
||||
schedules: list[dict[str, Any]] = service_call.data[
|
||||
RenaultServiceArgument.SCHEDULES
|
||||
]
|
||||
proxy = get_vehicle_proxy(service_call)
|
||||
hvac_schedules = await proxy.get_hvac_settings()
|
||||
|
||||
@@ -168,7 +178,7 @@ async def ac_set_schedules(service_call: ServiceCall) -> None:
|
||||
def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy:
|
||||
"""Get vehicle from service_call data."""
|
||||
device_registry = dr.async_get(service_call.hass)
|
||||
device_id = service_call.data[ATTR_VEHICLE]
|
||||
device_id = service_call.data[RenaultServiceArgument.VEHICLE]
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError(
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from roborock import (
|
||||
RoborockException,
|
||||
RoborockInvalidCredentials,
|
||||
@@ -120,6 +121,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="home_data_fail",
|
||||
) from err
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
_LOGGER.debug("Network error setting up Roborock: %s", err)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="network_error",
|
||||
) from err
|
||||
|
||||
async def shutdown_roborock(_: Event | None = None) -> None:
|
||||
await asyncio.gather(device_manager.close(), cache.flush())
|
||||
|
||||
@@ -677,6 +677,9 @@
|
||||
"mqtt_unauthorized": {
|
||||
"message": "Roborock MQTT servers rejected the connection due to rate limiting or invalid credentials. You may either attempt to reauthenticate or wait and reload the integration."
|
||||
},
|
||||
"network_error": {
|
||||
"message": "Network error connecting to Roborock servers. Check your internet connection and the Roborock service status."
|
||||
},
|
||||
"no_coordinators": {
|
||||
"message": "No devices were able to successfully setup"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,11 @@ class IRobotEntity(Entity):
|
||||
model=self.vacuum_state.get("sku"),
|
||||
name=str(self.vacuum_state.get("name")),
|
||||
sw_version=self.vacuum_state.get("softwareVer"),
|
||||
hw_version=self.vacuum_state.get("hardwareRev"),
|
||||
hw_version=(
|
||||
str(hw_rev)
|
||||
if (hw_rev := self.vacuum_state.get("hardwareRev")) is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
if mac_address := self.vacuum_state.get("hwPartsRev", {}).get(
|
||||
|
||||
@@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Button platform for Samsung IR integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from infrared_protocols.codes.samsung.tv import SamsungTVCode
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_EMITTER_ENTITY_ID, SamsungDeviceType
|
||||
from .entity import SamsungIrEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class SamsungIrButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Samsung IR button entity."""
|
||||
|
||||
command_code: SamsungTVCode
|
||||
|
||||
|
||||
TV_BUTTON_DESCRIPTIONS: tuple[SamsungIrButtonEntityDescription, ...] = (
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="power", translation_key="power", command_code=SamsungTVCode.POWER
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="source", translation_key="source", command_code=SamsungTVCode.SOURCE
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="settings", translation_key="settings", command_code=SamsungTVCode.SETTINGS
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="info", translation_key="info", command_code=SamsungTVCode.INFO
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="exit", translation_key="exit", command_code=SamsungTVCode.EXIT
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="return", translation_key="return", command_code=SamsungTVCode.RETURN
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="home", translation_key="home", command_code=SamsungTVCode.HOME
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="red", translation_key="red", command_code=SamsungTVCode.RED
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="green", translation_key="green", command_code=SamsungTVCode.GREEN
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="yellow", translation_key="yellow", command_code=SamsungTVCode.YELLOW
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="blue", translation_key="blue", command_code=SamsungTVCode.BLUE
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="up", translation_key="up", command_code=SamsungTVCode.NAV_UP
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="down", translation_key="down", command_code=SamsungTVCode.NAV_DOWN
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="left", translation_key="left", command_code=SamsungTVCode.NAV_LEFT
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="right", translation_key="right", command_code=SamsungTVCode.NAV_RIGHT
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="ok", translation_key="ok", command_code=SamsungTVCode.OK
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="previous_channel",
|
||||
translation_key="previous_channel",
|
||||
command_code=SamsungTVCode.PREVIOUS_CHANNEL,
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_0", translation_key="num_0", command_code=SamsungTVCode.NUM_0
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_1", translation_key="num_1", command_code=SamsungTVCode.NUM_1
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_2", translation_key="num_2", command_code=SamsungTVCode.NUM_2
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_3", translation_key="num_3", command_code=SamsungTVCode.NUM_3
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_4", translation_key="num_4", command_code=SamsungTVCode.NUM_4
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_5", translation_key="num_5", command_code=SamsungTVCode.NUM_5
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_6", translation_key="num_6", command_code=SamsungTVCode.NUM_6
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_7", translation_key="num_7", command_code=SamsungTVCode.NUM_7
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_8", translation_key="num_8", command_code=SamsungTVCode.NUM_8
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="num_9", translation_key="num_9", command_code=SamsungTVCode.NUM_9
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="fast_forward",
|
||||
translation_key="fast_forward",
|
||||
command_code=SamsungTVCode.FAST_FORWARD,
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="rewind", translation_key="rewind", command_code=SamsungTVCode.REWIND
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="record", translation_key="record", command_code=SamsungTVCode.RECORD
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="tools", translation_key="tools", command_code=SamsungTVCode.TOOLS
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="browser", translation_key="browser", command_code=SamsungTVCode.BROWSER
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="ad_subtitle",
|
||||
translation_key="ad_subtitle",
|
||||
command_code=SamsungTVCode.AD_SUBTITLE,
|
||||
),
|
||||
SamsungIrButtonEntityDescription(
|
||||
key="e_manual",
|
||||
translation_key="e_manual",
|
||||
command_code=SamsungTVCode.E_MANUAL,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Samsung IR buttons from config entry."""
|
||||
infrared_emitter_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID]
|
||||
device_type = entry.data[CONF_DEVICE_TYPE]
|
||||
if device_type != SamsungDeviceType.TV:
|
||||
return
|
||||
async_add_entities(
|
||||
[
|
||||
SamsungIrButton(entry, infrared_emitter_entity_id, description)
|
||||
for description in TV_BUTTON_DESCRIPTIONS
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class SamsungIrButton(SamsungIrEntity, InfraredEmitterConsumerEntity, ButtonEntity):
|
||||
"""Samsung IR button entity."""
|
||||
|
||||
entity_description: SamsungIrButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
infrared_emitter_entity_id: str,
|
||||
description: SamsungIrButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Samsung IR button."""
|
||||
super().__init__(entry, unique_id_suffix=description.key)
|
||||
self._infrared_emitter_entity_id = infrared_emitter_entity_id
|
||||
self.entity_description = description
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
await self._send_command(self.entity_description.command_code.to_command())
|
||||
@@ -19,6 +19,112 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"ad_subtitle": {
|
||||
"name": "AD/Subtitle"
|
||||
},
|
||||
"blue": {
|
||||
"name": "Blue"
|
||||
},
|
||||
"browser": {
|
||||
"name": "Browser"
|
||||
},
|
||||
"down": {
|
||||
"name": "[%key:common::entity::button::down::name%]"
|
||||
},
|
||||
"e_manual": {
|
||||
"name": "E-Manual"
|
||||
},
|
||||
"exit": {
|
||||
"name": "[%key:common::entity::button::exit::name%]"
|
||||
},
|
||||
"fast_forward": {
|
||||
"name": "Fast forward"
|
||||
},
|
||||
"green": {
|
||||
"name": "Green"
|
||||
},
|
||||
"home": {
|
||||
"name": "[%key:common::entity::button::home::name%]"
|
||||
},
|
||||
"info": {
|
||||
"name": "[%key:common::entity::button::info::name%]"
|
||||
},
|
||||
"left": {
|
||||
"name": "[%key:common::entity::button::left::name%]"
|
||||
},
|
||||
"num_0": {
|
||||
"name": "[%key:common::entity::button::num_0::name%]"
|
||||
},
|
||||
"num_1": {
|
||||
"name": "[%key:common::entity::button::num_1::name%]"
|
||||
},
|
||||
"num_2": {
|
||||
"name": "[%key:common::entity::button::num_2::name%]"
|
||||
},
|
||||
"num_3": {
|
||||
"name": "[%key:common::entity::button::num_3::name%]"
|
||||
},
|
||||
"num_4": {
|
||||
"name": "[%key:common::entity::button::num_4::name%]"
|
||||
},
|
||||
"num_5": {
|
||||
"name": "[%key:common::entity::button::num_5::name%]"
|
||||
},
|
||||
"num_6": {
|
||||
"name": "[%key:common::entity::button::num_6::name%]"
|
||||
},
|
||||
"num_7": {
|
||||
"name": "[%key:common::entity::button::num_7::name%]"
|
||||
},
|
||||
"num_8": {
|
||||
"name": "[%key:common::entity::button::num_8::name%]"
|
||||
},
|
||||
"num_9": {
|
||||
"name": "[%key:common::entity::button::num_9::name%]"
|
||||
},
|
||||
"ok": {
|
||||
"name": "[%key:common::entity::button::ok::name%]"
|
||||
},
|
||||
"power": {
|
||||
"name": "[%key:common::entity::button::power::name%]"
|
||||
},
|
||||
"previous_channel": {
|
||||
"name": "Previous channel"
|
||||
},
|
||||
"record": {
|
||||
"name": "Record"
|
||||
},
|
||||
"red": {
|
||||
"name": "Red"
|
||||
},
|
||||
"return": {
|
||||
"name": "Return"
|
||||
},
|
||||
"rewind": {
|
||||
"name": "Rewind"
|
||||
},
|
||||
"right": {
|
||||
"name": "[%key:common::entity::button::right::name%]"
|
||||
},
|
||||
"settings": {
|
||||
"name": "Settings"
|
||||
},
|
||||
"source": {
|
||||
"name": "Source"
|
||||
},
|
||||
"tools": {
|
||||
"name": "Tools"
|
||||
},
|
||||
"up": {
|
||||
"name": "[%key:common::entity::button::up::name%]"
|
||||
},
|
||||
"yellow": {
|
||||
"name": "Yellow"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"device_type": {
|
||||
"options": {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"requirements": [
|
||||
"getmac==0.9.5",
|
||||
"samsungctl[websocket]==0.7.1",
|
||||
"samsungtvws[async,encrypted]==2.7.2",
|
||||
"samsungtvws[async,encrypted]==3.0.5",
|
||||
"wakeonlan==3.3.0",
|
||||
"async-upnp-client==0.46.2"
|
||||
],
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
|
||||
from sense_energy import (
|
||||
ASyncSenseable,
|
||||
SenseAPIException,
|
||||
SenseAuthenticationException,
|
||||
SenseMFARequiredException,
|
||||
)
|
||||
@@ -88,6 +89,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SenseConfigEntry) -> boo
|
||||
) from err
|
||||
except SENSE_WEBSOCKET_EXCEPTIONS as err:
|
||||
raise ConfigEntryNotReady(str(err) or "Error during realtime update") from err
|
||||
except SenseAPIException as err:
|
||||
raise ConfigEntryNotReady(
|
||||
str(err) or "API error retrieving realtime data"
|
||||
) from err
|
||||
|
||||
trends_coordinator = SenseTrendCoordinator(hass, entry, gateway)
|
||||
realtime_coordinator = SenseRealtimeCoordinator(hass, entry, gateway)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from sense_energy import (
|
||||
ASyncSenseable,
|
||||
SenseAPIException,
|
||||
SenseAuthenticationException,
|
||||
SenseMFARequiredException,
|
||||
)
|
||||
@@ -93,6 +94,8 @@ class SenseRealtimeCoordinator(SenseCoordinator):
|
||||
try:
|
||||
await self._gateway.update_realtime()
|
||||
except SENSE_TIMEOUT_EXCEPTIONS as ex:
|
||||
_LOGGER.error("Timeout retrieving data: %s", ex)
|
||||
raise UpdateFailed(f"Timeout retrieving realtime data: {ex}") from ex
|
||||
except SENSE_WEBSOCKET_EXCEPTIONS as ex:
|
||||
_LOGGER.error("Failed to update data: %s", ex)
|
||||
raise UpdateFailed(f"Failed to update realtime data: {ex}") from ex
|
||||
except SenseAPIException as ex:
|
||||
raise UpdateFailed(f"API error retrieving realtime data: {ex}") from ex
|
||||
|
||||
@@ -132,6 +132,9 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
|
||||
|
||||
device = entry.runtime_data.rpc.device
|
||||
|
||||
if not device.initialized:
|
||||
return
|
||||
|
||||
if (
|
||||
(ws_config := device.config.get("ws"))
|
||||
and ws_config["enable"]
|
||||
@@ -169,6 +172,9 @@ def async_manage_open_wifi_ap_issue(
|
||||
|
||||
device = entry.runtime_data.rpc.device
|
||||
|
||||
if not device.initialized:
|
||||
return
|
||||
|
||||
# Check if WiFi AP is enabled and is open (no password)
|
||||
if (
|
||||
(wifi_config := device.config.get("wifi"))
|
||||
|
||||
@@ -72,8 +72,10 @@ async def async_setup_entry(
|
||||
for device in entry_data.devices.values()
|
||||
for component in device.status
|
||||
if (
|
||||
Capability.SWITCH in device.status[MAIN]
|
||||
and any(capability in device.status[MAIN] for capability in CAPABILITIES)
|
||||
Capability.SWITCH in device.status[component]
|
||||
and any(
|
||||
capability in device.status[component] for capability in CAPABILITIES
|
||||
)
|
||||
and Capability.SAMSUNG_CE_LAMP not in device.status[component]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -50,6 +50,7 @@ DEVICE_CLASS_MAP: dict[Category | str, MediaPlayerDeviceClass] = {
|
||||
Category.SPEAKER: MediaPlayerDeviceClass.SPEAKER,
|
||||
Category.TELEVISION: MediaPlayerDeviceClass.TV,
|
||||
Category.RECEIVER: MediaPlayerDeviceClass.RECEIVER,
|
||||
Category.PROJECTOR: MediaPlayerDeviceClass.PROJECTOR,
|
||||
}
|
||||
|
||||
VALUE_TO_STATE = {
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any
|
||||
import switchbot
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
|
||||
from homeassistant.components.sensor import ConfigType
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -310,6 +311,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) ->
|
||||
translation_placeholders={
|
||||
"sensor_type": entry.data[CONF_SENSOR_TYPE],
|
||||
"address": entry.data[CONF_ADDRESS],
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
entry.data[CONF_ADDRESS].upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -331,7 +337,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) ->
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found_error",
|
||||
translation_placeholders={"sensor_type": sensor_type, "address": address},
|
||||
translation_placeholders={
|
||||
"sensor_type": sensor_type,
|
||||
"address": address,
|
||||
"reason": bluetooth.async_address_reachability_diagnostics(
|
||||
hass,
|
||||
address.upper(),
|
||||
BluetoothReachabilityIntent.CONNECTION
|
||||
if connectable
|
||||
else BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice)
|
||||
|
||||
@@ -384,7 +384,7 @@
|
||||
"message": "The device ID {device_id} does not belong to SwitchBot integration."
|
||||
},
|
||||
"device_not_found_error": {
|
||||
"message": "Could not find Switchbot {sensor_type} with address {address}"
|
||||
"message": "Could not find Switchbot {sensor_type} with address {address}: {reason}"
|
||||
},
|
||||
"device_without_config_entry": {
|
||||
"message": "The device ID {device_id} is not associated with a config entry."
|
||||
|
||||
@@ -40,6 +40,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
login_task: asyncio.Task | None = None
|
||||
refresh_token: str | None = None
|
||||
tado: Tado | None = None
|
||||
tado_device_url: str = ""
|
||||
user_code: str = ""
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
@@ -69,8 +71,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Error while initiating Tado")
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
assert self.tado is not None
|
||||
tado_device_url = self.tado.device_verification_url()
|
||||
user_code = URL(tado_device_url).query["user_code"]
|
||||
self.tado_device_url = self.tado.device_verification_url()
|
||||
self.user_code = URL(self.tado_device_url).query["user_code"]
|
||||
|
||||
async def _wait_for_login() -> None:
|
||||
"""Wait for the user to login."""
|
||||
@@ -119,8 +121,8 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user",
|
||||
progress_action="wait_for_device",
|
||||
description_placeholders={
|
||||
"url": tado_device_url,
|
||||
"code": user_code,
|
||||
"url": self.tado_device_url,
|
||||
"code": self.user_code,
|
||||
},
|
||||
progress_task=self.login_task,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -83,11 +82,3 @@ class TeslaFleetDeviceTrackerRouteEntity(TeslaFleetDeviceTrackerEntity):
|
||||
self.get("drive_state_active_route_longitude", False) is None
|
||||
or self.get("drive_state_active_route_latitude", False) is None
|
||||
)
|
||||
|
||||
@property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device."""
|
||||
location = self.get("drive_state_active_route_destination")
|
||||
if location == "Home":
|
||||
return STATE_HOME
|
||||
return location
|
||||
|
||||
@@ -280,6 +280,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfLength.MILES,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
),
|
||||
TeslaFleetSensorEntityDescription(
|
||||
key="drive_state_active_route_destination",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.components.device_tracker import (
|
||||
TrackerEntity,
|
||||
TrackerEntityDescription,
|
||||
)
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -31,12 +30,6 @@ class TeslemetryDeviceTrackerEntityDescription(TrackerEntityDescription):
|
||||
[TeslemetryStreamVehicle, Callable[[TeslaLocation | None], None]],
|
||||
Callable[[], None],
|
||||
]
|
||||
name_listener: (
|
||||
Callable[
|
||||
[TeslemetryStreamVehicle, Callable[[str | None], None]], Callable[[], None]
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
streaming_firmware: str
|
||||
polling_prefix: str | None = None
|
||||
|
||||
@@ -54,9 +47,6 @@ DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = (
|
||||
value_listener=lambda vehicle, callback: vehicle.listen_DestinationLocation(
|
||||
callback
|
||||
),
|
||||
name_listener=lambda vehicle, callback: vehicle.listen_DestinationName(
|
||||
callback
|
||||
),
|
||||
streaming_firmware="2024.26",
|
||||
),
|
||||
TeslemetryDeviceTrackerEntityDescription(
|
||||
@@ -126,11 +116,6 @@ class TeslemetryVehiclePollingDeviceTrackerEntity(
|
||||
self._attr_longitude = self.get(
|
||||
f"{self.entity_description.polling_prefix}_longitude"
|
||||
)
|
||||
self._attr_location_name = self.get(
|
||||
f"{self.entity_description.polling_prefix}_destination"
|
||||
)
|
||||
if self._attr_location_name == "Home":
|
||||
self._attr_location_name = STATE_HOME
|
||||
self._attr_available = (
|
||||
self._attr_latitude is not None and self._attr_longitude is not None
|
||||
)
|
||||
@@ -158,28 +143,14 @@ class TeslemetryStreamingDeviceTrackerEntity(
|
||||
if (state := await self.async_get_last_state()) is not None:
|
||||
self._attr_latitude = state.attributes.get("latitude")
|
||||
self._attr_longitude = state.attributes.get("longitude")
|
||||
self._attr_location_name = state.attributes.get("location_name")
|
||||
self.async_on_remove(
|
||||
self.entity_description.value_listener(
|
||||
self.vehicle.stream_vehicle, self._location_callback
|
||||
)
|
||||
)
|
||||
if self.entity_description.name_listener:
|
||||
self.async_on_remove(
|
||||
self.entity_description.name_listener(
|
||||
self.vehicle.stream_vehicle, self._name_callback
|
||||
)
|
||||
)
|
||||
|
||||
def _location_callback(self, location: TeslaLocation | None) -> None:
|
||||
"""Update the value of the entity."""
|
||||
self._attr_latitude = None if location is None else location.latitude
|
||||
self._attr_longitude = None if location is None else location.longitude
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _name_callback(self, name: str | None) -> None:
|
||||
"""Update the value of the entity."""
|
||||
self._attr_location_name = name
|
||||
if self._attr_location_name == "Home":
|
||||
self._attr_location_name = STATE_HOME
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -510,6 +510,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
TeslemetryVehicleSensorEntityDescription(
|
||||
key="drive_state_active_route_destination",
|
||||
polling=True,
|
||||
streaming_listener=lambda vehicle, callback: vehicle.listen_DestinationName(
|
||||
callback
|
||||
),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
TeslemetryVehicleSensorEntityDescription(
|
||||
key="drive_state_active_route_traffic_minutes_delay",
|
||||
polling=True,
|
||||
|
||||
@@ -75,6 +75,10 @@ class VolvoLock(VolvoEntity, LockEntity):
|
||||
|
||||
def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None:
|
||||
"""Update the state of the entity."""
|
||||
if api_field is None:
|
||||
self._attr_is_locked = None
|
||||
return
|
||||
|
||||
assert isinstance(api_field, VolvoCarsValue)
|
||||
self._attr_is_locked = api_field.value == "LOCKED"
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
|
||||
|
||||
async def on_restart(self) -> None:
|
||||
"""Block until pipeline loop will be restarted."""
|
||||
_LOGGER.warning(
|
||||
_LOGGER.debug(
|
||||
"Satellite has been disconnected. Reconnecting in %s second(s)",
|
||||
_RECONNECT_SECONDS,
|
||||
)
|
||||
|
||||
@@ -6,8 +6,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
LocalOAuth2ImplementationWithPkce,
|
||||
)
|
||||
|
||||
from .const import YOTO_AUDIENCE, YOTO_SCOPES
|
||||
|
||||
AUTHORIZE_URL = "https://login.yotoplay.com/authorize"
|
||||
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
|
||||
|
||||
@@ -16,9 +14,9 @@ async def async_get_auth_implementation(
|
||||
hass: HomeAssistant,
|
||||
auth_domain: str,
|
||||
credential: ClientCredential,
|
||||
) -> YotoOAuth2Implementation:
|
||||
"""Return a Yoto OAuth2 implementation backed by the user's credential."""
|
||||
return YotoOAuth2Implementation(
|
||||
) -> LocalOAuth2ImplementationWithPkce:
|
||||
"""Return a Yoto OAuth2 implementation with PKCE."""
|
||||
return LocalOAuth2ImplementationWithPkce(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential.client_id,
|
||||
@@ -26,15 +24,3 @@ async def async_get_auth_implementation(
|
||||
TOKEN_URL,
|
||||
credential.client_secret,
|
||||
)
|
||||
|
||||
|
||||
class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
|
||||
"""Yoto OAuth2 implementation with PKCE, audience and scopes."""
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Append Yoto's audience and scopes to every authorize URL."""
|
||||
return super().extra_authorize_data | {
|
||||
"audience": YOTO_AUDIENCE,
|
||||
"scope": " ".join(YOTO_SCOPES),
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from yoto_api import YotoError, get_account_id
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .const import _LOGGER, DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES
|
||||
|
||||
|
||||
class YotoOAuth2FlowHandler(
|
||||
@@ -23,6 +23,14 @@ class YotoOAuth2FlowHandler(
|
||||
"""Return the logger used for the OAuth2 flow."""
|
||||
return _LOGGER
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, Any]:
|
||||
"""Append Yoto's audience and scopes to the authorize URL."""
|
||||
return {
|
||||
"audience": YOTO_AUDIENCE,
|
||||
"scope": " ".join(YOTO_SCOPES),
|
||||
}
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Identify the Yoto account from the access token."""
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,7 @@ import zeversolar
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -35,4 +35,7 @@ class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]):
|
||||
|
||||
async def _async_update_data(self) -> zeversolar.ZeverSolarData:
|
||||
"""Fetch the latest data from the source."""
|
||||
return await self.hass.async_add_executor_job(self._client.get_data)
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(self._client.get_data)
|
||||
except zeversolar.ZeverSolarError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfPower
|
||||
from homeassistant.const import UnitOfEnergy, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -29,10 +29,8 @@ class ZeversolarEntityDescription(SensorEntityDescription):
|
||||
SENSOR_TYPES = (
|
||||
ZeversolarEntityDescription(
|
||||
key="pac",
|
||||
translation_key="pac",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
value_fn=lambda data: data.pac,
|
||||
),
|
||||
|
||||
@@ -3,5 +3,13 @@
|
||||
"reload": {
|
||||
"service": "mdi:reload"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"entered": {
|
||||
"trigger": "mdi:map-marker-plus"
|
||||
},
|
||||
"left": {
|
||||
"trigger": "mdi:map-marker-minus"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,48 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least",
|
||||
"trigger_zone_description": "The zone to trigger on.",
|
||||
"trigger_zone_name": "Zone"
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"description": "Reloads zones from the YAML-configuration.",
|
||||
"name": "Reload zones"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"entered": {
|
||||
"description": "Triggers when one or more persons or device trackers enter a zone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::zone::common::trigger_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::zone::common::trigger_for_name%]"
|
||||
},
|
||||
"zone": {
|
||||
"description": "[%key:component::zone::common::trigger_zone_description%]",
|
||||
"name": "[%key:component::zone::common::trigger_zone_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Entered zone"
|
||||
},
|
||||
"left": {
|
||||
"description": "Triggers when one or more persons or device trackers leave a zone.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::zone::common::trigger_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::zone::common::trigger_for_name%]"
|
||||
},
|
||||
"zone": {
|
||||
"description": "[%key:component::zone::common::trigger_zone_description%]",
|
||||
"name": "[%key:component::zone::common::trigger_zone_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Left zone"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
"""Offer zone automation rules."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import ATTR_IN_ZONES
|
||||
from homeassistant.const import (
|
||||
ATTR_FRIENDLY_NAME,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_EVENT,
|
||||
CONF_PLATFORM,
|
||||
CONF_OPTIONS,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HassJob,
|
||||
HomeAssistant,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
@@ -24,8 +26,18 @@ from homeassistant.helpers import (
|
||||
entity_registry as er,
|
||||
location,
|
||||
)
|
||||
from homeassistant.helpers.automation import (
|
||||
DomainSpec,
|
||||
move_top_level_schema_fields_to_options,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
TriggerActionRunner,
|
||||
TriggerConfig,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import condition
|
||||
@@ -38,93 +50,166 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"}
|
||||
|
||||
_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
|
||||
_LEGACY_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
|
||||
vol.Required(CONF_ZONE): cv.entity_id,
|
||||
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(EVENT_ENTER, EVENT_LEAVE),
|
||||
}
|
||||
|
||||
_LEGACY_TRIGGER_OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PLATFORM): "zone",
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
|
||||
vol.Required(CONF_ZONE): cv.entity_id,
|
||||
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(
|
||||
EVENT_ENTER, EVENT_LEAVE
|
||||
),
|
||||
vol.Required(CONF_OPTIONS): _LEGACY_OPTIONS_SCHEMA,
|
||||
},
|
||||
)
|
||||
|
||||
# New-style zone trigger schema
|
||||
_ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_ZONE): cv.entity_domain("zone"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_validate_trigger_config(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate trigger config."""
|
||||
config = _TRIGGER_SCHEMA(config)
|
||||
registry = er.async_get(hass)
|
||||
config[CONF_ENTITY_ID] = er.async_validate_entity_ids(
|
||||
registry, config[CONF_ENTITY_ID]
|
||||
)
|
||||
return config
|
||||
_DOMAIN_SPECS: dict[str, DomainSpec] = {
|
||||
"person": DomainSpec(),
|
||||
"device_tracker": DomainSpec(),
|
||||
}
|
||||
|
||||
|
||||
async def async_attach_trigger(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
action: TriggerActionType,
|
||||
trigger_info: TriggerInfo,
|
||||
*,
|
||||
platform_type: str = "zone",
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Listen for state changes based on configuration."""
|
||||
trigger_data = trigger_info["trigger_data"]
|
||||
entity_id: list[str] = config[CONF_ENTITY_ID]
|
||||
zone_entity_id: str = config[CONF_ZONE]
|
||||
event: str = config[CONF_EVENT]
|
||||
job = HassJob(action)
|
||||
class LegacyZoneTrigger(Trigger):
|
||||
"""Legacy zone trigger (platform: zone)."""
|
||||
|
||||
@callback
|
||||
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
|
||||
"""Listen for state changes and calls action."""
|
||||
entity = zone_event.data["entity_id"]
|
||||
from_s = zone_event.data["old_state"]
|
||||
to_s = zone_event.data["new_state"]
|
||||
@classmethod
|
||||
async def async_validate_complete_config(
|
||||
cls, hass: HomeAssistant, complete_config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate complete config, migrating legacy format to options."""
|
||||
complete_config = move_top_level_schema_fields_to_options(
|
||||
complete_config, _LEGACY_OPTIONS_SCHEMA
|
||||
)
|
||||
return await super().async_validate_complete_config(hass, complete_config)
|
||||
|
||||
if (from_s and not location.has_location(from_s)) or (
|
||||
to_s and not location.has_location(to_s)
|
||||
):
|
||||
return
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
config = cast(ConfigType, _LEGACY_TRIGGER_OPTIONS_SCHEMA(config))
|
||||
registry = er.async_get(hass)
|
||||
config[CONF_OPTIONS][CONF_ENTITY_ID] = er.async_validate_entity_ids(
|
||||
registry, config[CONF_OPTIONS][CONF_ENTITY_ID]
|
||||
)
|
||||
return config
|
||||
|
||||
if not (zone_state := hass.states.get(zone_entity_id)):
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Automation '%s' is referencing non-existing zone '%s' in a zone"
|
||||
" trigger"
|
||||
),
|
||||
trigger_info["name"],
|
||||
zone_entity_id,
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
self._options = config.options
|
||||
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Listen for state changes based on configuration."""
|
||||
entity_id: list[str] = self._options[CONF_ENTITY_ID]
|
||||
zone_entity_id: str = self._options[CONF_ZONE]
|
||||
event: str = self._options[CONF_EVENT]
|
||||
|
||||
@callback
|
||||
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
|
||||
"""Listen for state changes and calls action."""
|
||||
entity = zone_event.data["entity_id"]
|
||||
from_s = zone_event.data["old_state"]
|
||||
to_s = zone_event.data["new_state"]
|
||||
|
||||
if (from_s and not location.has_location(from_s)) or (
|
||||
to_s and not location.has_location(to_s)
|
||||
):
|
||||
return
|
||||
|
||||
if not (zone_state := self._hass.states.get(zone_entity_id)):
|
||||
_LOGGER.warning(
|
||||
"Non-existing zone '%s' in a zone trigger",
|
||||
zone_entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
from_match = (
|
||||
condition.zone(self._hass, zone_state, from_s) if from_s else False
|
||||
)
|
||||
return
|
||||
to_match = condition.zone(self._hass, zone_state, to_s) if to_s else False
|
||||
|
||||
from_match = condition.zone(hass, zone_state, from_s) if from_s else False
|
||||
to_match = condition.zone(hass, zone_state, to_s) if to_s else False
|
||||
|
||||
if (event == EVENT_ENTER and not from_match and to_match) or (
|
||||
event == EVENT_LEAVE and from_match and not to_match
|
||||
):
|
||||
description = (
|
||||
f"{entity} {_EVENT_DESCRIPTION[event]}"
|
||||
f" {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
|
||||
)
|
||||
hass.async_run_hass_job(
|
||||
job,
|
||||
{
|
||||
"trigger": {
|
||||
**trigger_data,
|
||||
"platform": platform_type,
|
||||
if (event == EVENT_ENTER and not from_match and to_match) or (
|
||||
event == EVENT_LEAVE and from_match and not to_match
|
||||
):
|
||||
description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
|
||||
run_action(
|
||||
{
|
||||
"entity_id": entity,
|
||||
"from_state": from_s,
|
||||
"to_state": to_s,
|
||||
"zone": zone_state,
|
||||
"event": event,
|
||||
"description": description,
|
||||
}
|
||||
},
|
||||
to_s.context if to_s else None,
|
||||
)
|
||||
},
|
||||
description,
|
||||
to_s.context if to_s else None,
|
||||
)
|
||||
|
||||
return async_track_state_change_event(hass, entity_id, zone_automation_listener)
|
||||
return async_track_state_change_event(
|
||||
self._hass, entity_id, zone_automation_listener
|
||||
)
|
||||
|
||||
|
||||
class ZoneTriggerBase(EntityTriggerBase):
|
||||
"""Base for zone-based triggers targeting person and device_tracker entities."""
|
||||
|
||||
_domain_specs = _DOMAIN_SPECS
|
||||
_schema = _ZONE_TRIGGER_SCHEMA
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the trigger."""
|
||||
super().__init__(hass, config)
|
||||
self._zone: str = self._options[CONF_ZONE]
|
||||
|
||||
def _in_target_zone(self, state: State) -> bool:
|
||||
"""Check if the entity is in the selected zone."""
|
||||
in_zones = state.attributes.get(ATTR_IN_ZONES) or ()
|
||||
return self._zone in in_zones
|
||||
|
||||
|
||||
class EnteredZoneTrigger(ZoneTriggerBase):
|
||||
"""Trigger when an entity enters the selected zone."""
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check that the entity was not already in the selected zone."""
|
||||
return not self._in_target_zone(from_state)
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the entity is now in the selected zone."""
|
||||
return self._in_target_zone(state)
|
||||
|
||||
|
||||
class LeftZoneTrigger(ZoneTriggerBase):
|
||||
"""Trigger when an entity leaves the selected zone."""
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check that the entity was previously in the selected zone."""
|
||||
return self._in_target_zone(from_state)
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check that the entity is no longer in the selected zone."""
|
||||
return not self._in_target_zone(state)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"_": LegacyZoneTrigger,
|
||||
"entered": EnteredZoneTrigger,
|
||||
"left": LeftZoneTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for zones."""
|
||||
return TRIGGERS
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
.trigger_zone: &trigger_zone
|
||||
target:
|
||||
entity:
|
||||
domain:
|
||||
- person
|
||||
- device_tracker
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
zone:
|
||||
required: true
|
||||
selector:
|
||||
entity:
|
||||
domain: zone
|
||||
|
||||
entered: *trigger_zone
|
||||
left: *trigger_zone
|
||||
@@ -138,6 +138,8 @@ SAVE_DELAY = 1
|
||||
|
||||
DISCOVERY_COOLDOWN = 1
|
||||
|
||||
SETUP_RETRY_MAX_WAIT = 600 # 10 minutes
|
||||
|
||||
ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision"
|
||||
UNIQUE_ID_COLLISION_TITLE_LIMIT = 5
|
||||
|
||||
@@ -836,7 +838,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
error_reason_translation_key,
|
||||
error_reason_translation_placeholders,
|
||||
)
|
||||
wait_time = 2 ** min(self._tries, 4) * 5 + (
|
||||
wait_time = min(2**self._tries * 5, SETUP_RETRY_MAX_WAIT) + (
|
||||
randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000
|
||||
)
|
||||
self._tries += 1
|
||||
|
||||
Generated
+1
@@ -59,6 +59,7 @@ FLOWS = {
|
||||
"amberelectric",
|
||||
"ambient_network",
|
||||
"ambient_station",
|
||||
"analytics",
|
||||
"analytics_insights",
|
||||
"android_ip_webcam",
|
||||
"androidtv",
|
||||
|
||||
@@ -3759,6 +3759,12 @@
|
||||
"iot_class": "cloud_push",
|
||||
"name": "LG ThinQ"
|
||||
},
|
||||
"lg_tv_rs232": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
"name": "LG TV via Serial"
|
||||
},
|
||||
"webostv": {
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
@@ -3767,12 +3773,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"lg_tv_rs232": {
|
||||
"name": "LG TV via Serial",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"libre_hardware_monitor": {
|
||||
"name": "Libre Hardware Monitor",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -31,7 +31,6 @@ from homeassistant.const import (
|
||||
MAX_LENGTH_STATE_DOMAIN,
|
||||
MAX_LENGTH_STATE_ENTITY_ID,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
Platform,
|
||||
)
|
||||
@@ -1951,9 +1950,10 @@ class EntityRegistry(BaseRegistry):
|
||||
This should only be used when an entity needs to be migrated between
|
||||
integrations.
|
||||
"""
|
||||
if (
|
||||
state := self.hass.states.get(entity_id)
|
||||
) is not None and state.state != STATE_UNKNOWN:
|
||||
# import here to avoid circular import
|
||||
from .entity import entity_sources # noqa: PLC0415
|
||||
|
||||
if entity_id in entity_sources(self.hass):
|
||||
raise ValueError("Only entities that haven't been loaded can be migrated")
|
||||
|
||||
old = self.entities[entity_id]
|
||||
|
||||
@@ -1714,7 +1714,14 @@ def async_extract_entities(trigger_conf: dict) -> list[str]:
|
||||
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "zone":
|
||||
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]
|
||||
options = trigger_conf[CONF_OPTIONS]
|
||||
return [*options[CONF_ENTITY_ID], options[CONF_ZONE]]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] in ("zone.entered", "zone.left"):
|
||||
return [
|
||||
*async_extract_targets(trigger_conf, CONF_ENTITY_ID),
|
||||
trigger_conf[CONF_OPTIONS][CONF_ZONE],
|
||||
]
|
||||
|
||||
if trigger_conf[CONF_PLATFORM] == "geo_location":
|
||||
return [trigger_conf[CONF_ZONE]]
|
||||
|
||||
@@ -35,11 +35,11 @@ file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.3
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==6.7.9
|
||||
habluetooth==6.8.0
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
home-assistant-frontend==20260527.0
|
||||
home-assistant-frontend==20260527.1
|
||||
home-assistant-intents==2026.5.5
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
@@ -99,6 +99,9 @@ Every check has a code following the
|
||||
| `W7407` | [`home-assistant-config-flow-polling-field`](#w7407-home-assistant-config-flow-polling-field) | Config flow should not include polling interval fields |
|
||||
| `W7408` | [`home-assistant-config-flow-name-field`](#w7408-home-assistant-config-flow-name-field) | Config flow should not include name fields |
|
||||
| `R7402` | [`home-assistant-unused-test-fixture-argument`](#r7402-home-assistant-unused-test-fixture-argument) | Unused test function argument should use `@pytest.mark.usefixtures` |
|
||||
| `W7418` | [`home-assistant-tests-direct-async-setup-entry`](#w7418-home-assistant-tests-direct-async-setup-entry) | Tests should not call an integration's `async_setup_entry` directly |
|
||||
| `W7420` | [`home-assistant-tests-direct-platform-async-setup-entry`](#w7420-home-assistant-tests-direct-platform-async-setup-entry) | Tests should not call a platform's `async_setup_entry` directly |
|
||||
| `W7421` | [`home-assistant-tests-direct-async-migrate-entry`](#w7421-home-assistant-tests-direct-async-migrate-entry) | Tests should not call an integration's `async_migrate_entry` directly |
|
||||
| `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly |
|
||||
|
||||
|
||||
@@ -342,6 +345,42 @@ only needed for its side effects.
|
||||
This rule only applies to `test_*` functions, not to fixture functions.
|
||||
|
||||
|
||||
## `home_assistant_tests_direct_async_setup_entry` checker
|
||||
|
||||
Detects tests that call an integration's `async_setup_entry` directly.
|
||||
|
||||
### `W7418`: `home-assistant-tests-direct-async-setup-entry`
|
||||
|
||||
Tests should not invoke an integration's `async_setup_entry` from
|
||||
`__init__.py` directly. Instead, tests should let Home Assistant perform
|
||||
the setup via `await hass.config_entries.async_setup(entry.entry_id)` so
|
||||
that the real setup pipeline (platforms, services, listeners, unload
|
||||
handlers, etc.) is exercised.
|
||||
|
||||
### `W7420`: `home-assistant-tests-direct-platform-async-setup-entry`
|
||||
|
||||
Same as `W7418`, but for an entity platform's `async_setup_entry` (e.g.
|
||||
`homeassistant.components.<integration>.sensor.async_setup_entry`).
|
||||
Tests should drive setup through `hass.config_entries.async_setup` so
|
||||
the platform is loaded via the normal Home Assistant flow.
|
||||
|
||||
See [epic #77](https://github.com/home-assistant/epics/issues/77).
|
||||
|
||||
|
||||
## `home_assistant_tests_direct_async_migrate_entry` checker
|
||||
|
||||
Detects tests that call an integration's `async_migrate_entry` directly.
|
||||
|
||||
### `W7421`: `home-assistant-tests-direct-async-migrate-entry`
|
||||
|
||||
Tests should not invoke an integration's `async_migrate_entry` from
|
||||
`__init__.py` directly. Instead, tests should let Home Assistant perform
|
||||
the setup via `await hass.config_entries.async_setup(entry.entry_id)` so
|
||||
that the real migration pipeline (version bumps, reloads, post-migration
|
||||
setup, etc.) is exercised.
|
||||
|
||||
See [epic #78](https://github.com/home-assistant/epics/issues/78).
|
||||
|
||||
|
||||
## `home_assistant_tests_direct_async_setup` checker
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Checker for direct calls to ``async_migrate_entry`` from tests.
|
||||
|
||||
Tests should not invoke an integration's ``async_migrate_entry``
|
||||
directly. Instead, tests should let Home Assistant trigger the
|
||||
migration as part of the normal setup pipeline via
|
||||
``await hass.config_entries.async_setup(entry.entry_id)`` so that the
|
||||
real migration flow (version updates, reloads, etc.) is exercised.
|
||||
|
||||
This checker flags any ``await <domain>.async_migrate_entry(...)``
|
||||
or ``await async_migrate_entry(...)`` call in a test module whose
|
||||
target resolves to a function defined in an integration's ``__init__``
|
||||
module under ``homeassistant.components.*``.
|
||||
"""
|
||||
|
||||
import astroid
|
||||
from astroid import nodes
|
||||
from pylint.checkers import BaseChecker
|
||||
from pylint.lint import PyLinter
|
||||
|
||||
from pylint_home_assistant.helpers.module_info import is_test_module, parse_module
|
||||
|
||||
|
||||
def _is_integration_async_migrate_entry(call: nodes.Call) -> bool:
|
||||
"""Return True if *call* targets an integration's ``async_migrate_entry``."""
|
||||
func = call.func
|
||||
match func:
|
||||
case nodes.Attribute(attrname="async_migrate_entry"):
|
||||
pass
|
||||
case nodes.Name(name="async_migrate_entry"):
|
||||
pass
|
||||
case _:
|
||||
return False
|
||||
|
||||
try:
|
||||
inferred_values = list(func.infer())
|
||||
except astroid.InferenceError, astroid.AstroidError:
|
||||
return False
|
||||
|
||||
seen_qnames: set[str] = set()
|
||||
for inferred in inferred_values:
|
||||
if inferred is astroid.Uninferable:
|
||||
continue
|
||||
if not isinstance(inferred, (nodes.FunctionDef, nodes.AsyncFunctionDef)):
|
||||
continue
|
||||
qname = inferred.qname()
|
||||
if not qname or qname in seen_qnames:
|
||||
continue
|
||||
seen_qnames.add(qname)
|
||||
# qname is the function's fully-qualified name, e.g.
|
||||
# ``homeassistant.components.sun.async_migrate_entry``. Strip the
|
||||
# function name to get the module and parse it.
|
||||
module_qname = qname.rsplit(".", 1)[0]
|
||||
parsed = parse_module(module_qname)
|
||||
if parsed is None:
|
||||
continue
|
||||
# ``async_migrate_entry`` lives in the integration's ``__init__``.
|
||||
if parsed.module is None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class DirectAsyncMigrateEntry(BaseChecker):
|
||||
"""Checker for direct calls to async_migrate_entry in tests."""
|
||||
|
||||
name = "home_assistant_tests_direct_async_migrate_entry"
|
||||
priority = -1
|
||||
msgs = {
|
||||
"W7421": (
|
||||
"Do not call `async_migrate_entry` directly from tests; use "
|
||||
"`await hass.config_entries.async_setup(entry.entry_id)` instead",
|
||||
"home-assistant-tests-direct-async-migrate-entry",
|
||||
"Used when a test module calls an integration's "
|
||||
"`async_migrate_entry` directly. Tests should let Home Assistant "
|
||||
"drive the setup so the migration flow is exercised through the "
|
||||
"normal pipeline.",
|
||||
),
|
||||
}
|
||||
options = ()
|
||||
|
||||
_in_test_module: bool = False
|
||||
|
||||
def visit_module(self, node: nodes.Module) -> None:
|
||||
"""Record whether the current module is a test module."""
|
||||
self._in_test_module = is_test_module(node.name)
|
||||
|
||||
def visit_call(self, node: nodes.Call) -> None:
|
||||
"""Flag direct calls to an integration's async_migrate_entry."""
|
||||
if not self._in_test_module:
|
||||
return
|
||||
if _is_integration_async_migrate_entry(node):
|
||||
self.add_message(
|
||||
"home-assistant-tests-direct-async-migrate-entry",
|
||||
node=node,
|
||||
)
|
||||
|
||||
|
||||
def register(linter: PyLinter) -> None:
|
||||
"""Register the checker."""
|
||||
linter.register_checker(DirectAsyncMigrateEntry(linter))
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Checker for direct calls to ``async_setup_entry`` from tests.
|
||||
|
||||
Tests should not invoke an integration's ``async_setup_entry`` directly
|
||||
(either the one in ``__init__.py`` or in an entity-platform module).
|
||||
Instead, tests should let Home Assistant perform the setup via
|
||||
``await hass.config_entries.async_setup(entry.entry_id)`` so that the
|
||||
real setup pipeline (platforms, services, listeners, unload handlers,
|
||||
etc.) is exercised.
|
||||
|
||||
This checker flags any call to ``async_setup_entry`` (whether awaited or
|
||||
not, accessed as a name or an attribute) made from a test module whose
|
||||
target resolves to a module-level function defined under
|
||||
``homeassistant.components.*``. The integration-init case and the
|
||||
entity-platform case get separate messages so violations can be tracked
|
||||
and fixed independently.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
import astroid
|
||||
from astroid import nodes
|
||||
from pylint.checkers import BaseChecker
|
||||
from pylint.lint import PyLinter
|
||||
|
||||
from pylint_home_assistant.helpers.module_info import is_test_module, parse_module
|
||||
|
||||
|
||||
class _SetupKind(Enum):
|
||||
"""The kind of integration ``async_setup_entry`` being called."""
|
||||
|
||||
INIT = "init"
|
||||
PLATFORM = "platform"
|
||||
|
||||
|
||||
def _resolve_integration_async_setup_entry(call: nodes.Call) -> _SetupKind | None:
|
||||
"""Return the kind of integration ``async_setup_entry`` *call* targets.
|
||||
|
||||
Returns ``_SetupKind.INIT`` if the target is in the integration's
|
||||
``__init__`` module, ``_SetupKind.PLATFORM`` if it is in an
|
||||
entity-platform module, or ``None`` if the call does not resolve to
|
||||
an integration's ``async_setup_entry``.
|
||||
"""
|
||||
func = call.func
|
||||
match func:
|
||||
case nodes.Attribute(attrname="async_setup_entry"):
|
||||
pass
|
||||
case nodes.Name(name="async_setup_entry"):
|
||||
pass
|
||||
case _:
|
||||
return None
|
||||
|
||||
seen_qnames: set[str] = set()
|
||||
try:
|
||||
for inferred in func.infer():
|
||||
if inferred is astroid.Uninferable:
|
||||
continue
|
||||
if not isinstance(inferred, (nodes.FunctionDef, nodes.AsyncFunctionDef)):
|
||||
continue
|
||||
# Require the function to be defined at module level so that
|
||||
# class methods named ``async_setup_entry`` (whose qname
|
||||
# includes the class name) are not classified as integration
|
||||
# setup functions.
|
||||
if not isinstance(inferred.parent, nodes.Module):
|
||||
continue
|
||||
module_qname = inferred.parent.qname()
|
||||
if not module_qname or module_qname in seen_qnames:
|
||||
continue
|
||||
seen_qnames.add(module_qname)
|
||||
parsed = parse_module(module_qname)
|
||||
if parsed is None:
|
||||
continue
|
||||
return _SetupKind.INIT if parsed.module is None else _SetupKind.PLATFORM
|
||||
except astroid.exceptions.InferenceError, astroid.exceptions.AstroidError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class DirectAsyncSetupEntry(BaseChecker):
|
||||
"""Checker for direct calls to async_setup_entry in tests."""
|
||||
|
||||
name = "home_assistant_tests_direct_async_setup_entry"
|
||||
priority = -1
|
||||
msgs = {
|
||||
"W7418": (
|
||||
(
|
||||
"Do not call `async_setup_entry` directly from tests; use "
|
||||
"`await hass.config_entries.async_setup(entry.entry_id)` instead"
|
||||
),
|
||||
"home-assistant-tests-direct-async-setup-entry",
|
||||
(
|
||||
"Used when a test module calls an integration's "
|
||||
"`async_setup_entry` from `__init__.py` directly. Tests should "
|
||||
"let Home Assistant drive the setup so the full setup pipeline "
|
||||
"is exercised."
|
||||
),
|
||||
),
|
||||
"W7420": (
|
||||
(
|
||||
"Do not call a platform's `async_setup_entry` directly from "
|
||||
"tests; use `await hass.config_entries.async_setup(entry.entry_id)`"
|
||||
" instead"
|
||||
),
|
||||
"home-assistant-tests-direct-platform-async-setup-entry",
|
||||
(
|
||||
"Used when a test module calls an integration entity platform's "
|
||||
"`async_setup_entry` directly. Tests should let Home Assistant "
|
||||
"drive the setup so the full setup pipeline is exercised."
|
||||
),
|
||||
),
|
||||
}
|
||||
options = ()
|
||||
|
||||
_in_test_module: bool = False
|
||||
|
||||
def visit_module(self, node: nodes.Module) -> None:
|
||||
"""Record whether the current module is a test module."""
|
||||
self._in_test_module = is_test_module(node.name)
|
||||
|
||||
def visit_call(self, node: nodes.Call) -> None:
|
||||
"""Flag direct calls to an integration's async_setup_entry."""
|
||||
if not self._in_test_module:
|
||||
return
|
||||
match _resolve_integration_async_setup_entry(node):
|
||||
case _SetupKind.INIT:
|
||||
self.add_message(
|
||||
"home-assistant-tests-direct-async-setup-entry",
|
||||
node=node,
|
||||
)
|
||||
case _SetupKind.PLATFORM:
|
||||
self.add_message(
|
||||
"home-assistant-tests-direct-platform-async-setup-entry",
|
||||
node=node,
|
||||
)
|
||||
|
||||
|
||||
def register(linter: PyLinter) -> None:
|
||||
"""Register the checker."""
|
||||
linter.register_checker(DirectAsyncSetupEntry(linter))
|
||||
+1
-1
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
|
||||
|
||||
from typing import Final
|
||||
|
||||
FRONTEND_VERSION: Final[str] = "20260527.0"
|
||||
FRONTEND_VERSION: Final[str] = "20260527.1"
|
||||
|
||||
MDI_ICONS: Final[set[str]] = {
|
||||
"ab-testing",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user