Compare commits

..

78 Commits

Author SHA1 Message Date
J. Nick Koston 434d5b54ae Fix ESPHome update entity stuck on for project versions with build suffix 2026-05-29 09:35:37 -05:00
epenet 85f3141776 Fix CI failure due to missing ssdp patching in braviatv (#172561) 2026-05-29 14:18:08 +02:00
Michael a175c7c4be Handle FileNotFoundError in Immich upload_file action (#172490) 2026-05-29 13:22:26 +02:00
Zach Wolf 03c83091ab Catch network errors during Roborock config entry setup (#172492)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 13:21:01 +02:00
mhuiskes accebd7f38 Remove diagnostic category and dead translation key from pac sensor (#172548) 2026-05-29 12:51:17 +02:00
epenet 9d3bb346e9 Refactor Renault to use StrEnum (#172546) 2026-05-29 11:42:04 +02:00
mhuiskes d13721980e Fix zeversolar coordinator to raise UpdateFailed on errors (#170507) 2026-05-29 11:26:27 +02:00
Franck Nijhof ac6b5a5850 Add missing ssdp dependency to BraviaTV manifest (#172536) 2026-05-29 11:17:36 +02:00
Franck Nijhof 16dfa99673 Use state-based icon for Hue grouped light (#172535) 2026-05-29 11:17:00 +02:00
Franck Nijhof f51a02bbda Fix Volvo lock crash when API field is missing from coordinator data (#172465) 2026-05-29 10:50:55 +02:00
Paul Bottein 6a51b21242 Fix Yoto OAuth flow with cloud credentials (#172544)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-29 10:30:52 +02:00
dependabot[bot] 5eb502851c Bump docker/login-action from 4.1.0 to 4.2.0 (#172531) 2026-05-29 08:54:25 +02:00
dependabot[bot] ef20418c76 Bump github/codeql-action from 4.35.5 to 4.36.0 (#172529) 2026-05-29 08:53:42 +02:00
Erwin Douna 94ca34fd0c Portainer refactor services test (#172525) 2026-05-29 08:21:09 +02:00
Franck Nijhof 8634c22a53 Guard Shelly repairs checks for uninitialized RPC devices (#172509) 2026-05-29 09:12:25 +03:00
Brett Adams 5681ba40f1 Move Teslemetry destination name from device tracker to a sensor (#172514)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 07:56:32 +02:00
Brett Adams 8a9a1c5fed Move Tesla Fleet route destination from device tracker to a sensor (#172513)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 07:55:44 +02:00
Franck Nijhof c587e101af Reduce Wyoming satellite disconnect log to debug level (#172499) 2026-05-28 19:18:14 -05:00
Franck Nijhof 6eeeac46f3 Convert Roomba hw_version to string for device registry (#172497) 2026-05-28 23:13:08 +02:00
Franck Nijhof 86542b8ad0 Increase ConfigEntryNotReady retry backoff cap from 80s to 10 minutes (#172487) 2026-05-28 22:41:54 +02:00
Franck Nijhof 7e07e7062c Add prog operating mode to Overkiz Atlantic heater HVAC mapping (#172491) 2026-05-28 22:21:53 +02:00
Franck Nijhof d7c13fee27 Fix Tado config flow crash on device activation polling (#172486) 2026-05-28 22:06:24 +02:00
Ronald van der Meer a0a44f7a25 Refactor Duco tests to use shared fixtures (#172351) 2026-05-28 22:04:25 +02:00
Mike Degatano 2bba907013 Migrate analytics integration to config entry setup (#171801)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 20:42:25 +02:00
Crocmagnon 0dcb8fc507 ovhcloud_ai_endpoints: update quality scale to silver (#172440) 2026-05-28 20:40:41 +02:00
Jan Bouwhuis 18e6f67650 Move MQTT protocol setting to main options (#172482) 2026-05-28 20:36:39 +02:00
Joost Lekkerkerker e5fad17e17 Add pylint rule for checking async_migrate_entry calls in tests (#171877) 2026-05-28 20:22:41 +02:00
Boris Obmoroshev 219b9cbcaa Add regression test for ONVIF setup against a real ONVIFDevice (#172194)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-28 19:18:24 +01:00
Franck Nijhof 309b26f809 Handle DAVError in CalDAV get_supported_components (#172479) 2026-05-28 19:53:20 +02:00
Bram Kragten e78cb0114d Bump frontend to 20260527.1 (#172462)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-28 19:52:47 +02:00
Crocmagnon 06a4247078 ovhcloud_ai_endpoints: increase test coverage (#172439) 2026-05-28 19:48:08 +02:00
Daniel Feinberg 181e21dd2c Fix apple_tv HomePod streaming failures when device is idle (#170033)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 19:47:32 +02:00
Crocmagnon 31354d4129 ovhcloud_ai_endpoints: add diagnostics (#172444) 2026-05-28 19:42:49 +02:00
Simone Chemelli 57308d7760 Discard old events for Alexa Devices (#172446) 2026-05-28 19:42:19 +02:00
Joost Lekkerkerker c07fed05df Add pylint rule for checking async_setup_entry calls in tests (#171864) 2026-05-28 19:28:29 +02:00
jtjart 13ef737873 Add projector as media player device class (#169274) 2026-05-28 19:27:21 +02:00
TheJulianJES 0a1510135c Fix Matter BLE proxy blocking startup (#172456) 2026-05-28 19:25:36 +02:00
Simone Chemelli 6f6b7888cd Bump samsungtvws to 3.0.5 (#172471) 2026-05-28 19:02:30 +02:00
Paul Bottein b9173e36fb Name the Broadlink RF transmitter entity (#172468) 2026-05-28 19:02:14 +02:00
Ronald van der Meer a65ca9c86b Fix Duco regression where entities become unavailable when LAN info fetch fails (#172448) 2026-05-28 19:00:43 +02:00
Paulus Schoutsen fc12d6fbb6 Add lg_tv_rs232 to LG brand (#172458)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-28 18:52:55 +02:00
Keilin Bickar 2a6b686254 Add Sense API exception handling (#169957)
Co-authored-by: Inca <inca@popre.net>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 17:42:43 +01:00
G Johansson 4d841e4d84 Update async_update_entity_platform to not allow loaded entities (#171773) 2026-05-28 18:17:23 +02:00
Lukas df08e9f311 Add button platform for Samsung Infrared integration (#171791)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 17:14:47 +01:00
Abílio Costa d53e40eea8 Add skill instruction on not duplicating entity base class behavior (#172362) 2026-05-28 16:03:43 +01:00
Franck Nijhof 0b261b7198 Fix SmartThings light checking wrong component for capabilities (#172430)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-28 16:27:57 +02:00
dependabot[bot] 3a9f32de25 Bump github/gh-aw-actions from 0.74.4 to 0.74.9 (#172398)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 13:52:56 +02:00
dependabot[bot] b5e54583c7 Bump docker/build-push-action from 7.1.0 to 7.2.0 (#172397)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 13:51:38 +02:00
Franck Nijhof 85ea7c1176 Fix Hue light ZeroDivisionError when mirek value is zero (#172442) 2026-05-28 13:50:45 +02:00
Franck Nijhof 713f520bc8 Fix iZone integration broken by python-izone 1.2.10 API change (#172427) 2026-05-28 13:48:19 +02:00
Michael Davie e4bb5a9395 Use ECMap for Environment Canada radar with layer support (#161602)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-28 13:47:58 +02:00
LG-ThinQ-Integration 936b2fe933 Remove unused translation in lg_thinq (#172394)
Co-authored-by: YunseonPark-LGE <yunseon.park@lge.com>
2026-05-28 13:44:56 +02:00
dependabot[bot] c6c6f08885 Bump dessant/lock-threads from 6.0.0 to 6.0.1 (#172399)
Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 13:40:03 +02:00
Ariel Ebersberger c621721851 Remove advanced options from config/test_config_entires (#172423) 2026-05-28 13:37:31 +02:00
Erik Montnemery 5bb6b20641 Add zone entered left triggers (#172412) 2026-05-28 13:22:38 +02:00
Manu 37f41d8e09 Fix index error in DuckDNS integration (#172392) 2026-05-28 12:58:51 +02:00
Crocmagnon b02f312bed ovhcloud_ai_endpoints: reauthentication flow (#172405) 2026-05-28 12:58:39 +02:00
Nikhil Deepak 3520c821c5 Reset MQTT valve opening/closing state at intermediate positions (#165176)
Co-authored-by: jbouwh <jan@jbsoft.nl>
2026-05-28 12:07:30 +02:00
Jan Bouwhuis cbf737a03e Improve MQTT protocol deprecation repair message (#172404)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 12:05:35 +02:00
Franck Nijhof 5bd6d52e6a Convert yamaha_musiccast sw_version to string (#172411) 2026-05-28 12:05:19 +02:00
Linkplay2020 d9a89beb3d Bump wiim to 1.0.4 (#172334)
Co-authored-by: Tao Jiang <tao.jiang@linkplay.com>
2026-05-28 11:38:22 +02:00
Ludovic BOUÉ 41f783f14d Add Matter soil moisture sensor (#172372) 2026-05-28 11:03:58 +02:00
Erik Montnemery 35397b818d Deprecate device tracker battery_level property (#171819)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 10:54:08 +02:00
Erik Montnemery d42d02f20a Revert "Add zone triggers entered/left zone" (#172409) 2026-05-28 10:32:28 +02:00
Franck Nijhof 99c445f261 Bump version to 2026.7.0dev0 (#172367) 2026-05-28 10:20:00 +02:00
Stefan Agner 567fe85828 Reject backup uploads with unsafe inner name (#172368)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:19:06 +02:00
Erik Montnemery fd1a5d0c5a Add zone triggers entered/left zone (#171751) 2026-05-28 10:05:41 +02:00
Erik Montnemery 632ec39d53 Deprecate device tracker TrackerEntity location_name property (#171820) 2026-05-28 10:02:28 +02:00
Abílio Costa 67b9d28953 Fix OMIE sensors not updating on setup (#172383) 2026-05-28 08:29:53 +02:00
J. Nick Koston e3880eedb0 Bump yalexs to 9.2.1 (#172389) 2026-05-27 22:01:07 -05:00
J. Nick Koston ce64f5f902 Bump onvif-zeep-async to 4.1.1 (#172391) 2026-05-27 22:00:56 -05:00
J. Nick Koston 0da99a50fc Bump dbus-fast to 5.0.16 (#172378) 2026-05-27 17:16:36 -05:00
Arcadiy Ivanov 43f636be65 Include device identity in Matter light transition blocklist warning (#172324) 2026-05-27 23:58:37 +02:00
Simone Chemelli 262cdbfab5 Bump aioamazondevices to 13.8.1 (#172382) 2026-05-27 23:16:23 +02:00
puddly 8cbd358435 Bump ZHA to 1.4.0 (#172357) 2026-05-27 22:55:07 +02:00
torben-iometer df04b19a0a bump iometer version to 1.0.1 (#172338) 2026-05-27 22:19:20 +02:00
markvp adeb352079 Add GeneralDiagnostics sensors and fault binary sensors to Matter integration (#169830) 2026-05-27 21:07:08 +02:00
Stefan Agner 1e457600f1 Harden backup tar extraction with Python tar_filter (#172252) 2026-05-27 18:10:04 +02:00
234 changed files with 18782 additions and 1478 deletions
@@ -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
+5 -5
View File
@@ -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 }}
@@ -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
View File
@@ -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 }}
+1 -1
View File
@@ -39,7 +39,7 @@ on:
env:
CACHE_VERSION: 3
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.6"
HA_SHORT_VERSION: "2026.7"
ADDITIONAL_PYTHON_VERSIONS: "[]"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
+2 -4
View File
@@ -92,8 +92,7 @@ def _extract_backup(
):
ostf.tar.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf.tar),
filter="fully_trusted",
filter="tar",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
@@ -119,8 +118,7 @@ def _extract_backup(
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),
members=securetar.secure_path(istf),
filter="fully_trusted",
filter="tar",
)
if restore_content.restore_homeassistant:
keep = list(KEEP_BACKUPS)
+1
View File
@@ -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
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.8.0"]
"requirements": ["aioamazondevices==13.8.1"]
}
+41 -16
View File
@@ -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": {
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
}
+16 -3
View File
@@ -11,7 +11,7 @@ from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback
from .const import DOMAIN, LOGGER
from .models import AgentBackup, BackupNotFound
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
from .util import read_backup, suggested_filename
@@ -54,7 +54,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
try:
backup = read_backup(backup_path)
backups[backup.backup_id] = (backup, backup_path)
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
except (
OSError,
TarError,
json.JSONDecodeError,
KeyError,
InvalidBackupFilename,
) as err:
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
return backups
@@ -122,7 +128,14 @@ class CoreLocalBackupAgent(LocalBackupAgent):
def get_new_backup_path(self, backup: AgentBackup) -> Path:
"""Return the local path to a new backup."""
return self._backup_dir / suggested_filename(backup)
candidate = self._backup_dir / suggested_filename(backup)
# suggested_filename does not strip separators; refuse paths that would
# land outside the backup directory.
if candidate.parent != self._backup_dir:
raise InvalidBackupFilename(
f"Refusing to write outside {self._backup_dir}: {candidate}"
)
return candidate
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file."""
+7 -1
View File
@@ -1978,7 +1978,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
try:
backup = await async_add_executor_job(read_backup, temp_file)
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err:
except (
OSError,
tarfile.TarError,
json.JSONDecodeError,
KeyError,
InvalidBackupFilename,
) as err:
LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
raise
+10 -3
View File
@@ -6,7 +6,7 @@ import copy
from dataclasses import dataclass, replace
from io import BytesIO
import json
from pathlib import Path, PurePath
from pathlib import Path, PurePath, PureWindowsPath
from queue import SimpleQueue
import tarfile
import threading
@@ -34,7 +34,7 @@ from homeassistant.util.async_iterator import (
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION
from .models import AddonInfo, AgentBackup, Folder
from .models import AddonInfo, AgentBackup, Folder, InvalidBackupFilename
class DecryptError(HomeAssistantError):
@@ -109,6 +109,13 @@ def read_backup(backup_path: Path) -> AgentBackup:
extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
date = extra_metadata.get("supervisor.backup_request_date", data["date"])
name = cast(str, data["name"])
# The name is used to derive the on-disk filename via suggested_filename;
# reject anything that could escape the backup directory.
safe_name = PureWindowsPath(name).name
if safe_name != name or name in ("", ".", ".."):
raise InvalidBackupFilename(f"Invalid backup name: {name!r}")
return AgentBackup(
addons=addons,
backup_id=cast(str, data["slug"]),
@@ -118,7 +125,7 @@ def read_backup(backup_path: Path) -> AgentBackup:
folders=folders,
homeassistant_included=homeassistant_included,
homeassistant_version=homeassistant_version,
name=cast(str, data["name"]),
name=name,
protected=cast(bool, data.get("protected", False)),
size=backup_path.stat().st_size,
)
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.3.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.15",
"dbus-fast==5.0.16",
"habluetooth==6.7.9"
]
}
@@ -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",
+2 -1
View File
@@ -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
)
@@ -1,6 +1,7 @@
"""Provide functionality to keep track of devices."""
import asyncio
import logging
from typing import TYPE_CHECKING, Any, final
from propcache.api import cached_property
@@ -22,6 +23,7 @@ from homeassistant.core import (
EventStateChangedData,
HomeAssistant,
State,
async_get_hass_or_none,
callback,
)
from homeassistant.helpers import (
@@ -37,6 +39,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.loader import async_suggest_report_issue
from homeassistant.util.hass_dict import HassKey
from .const import (
@@ -52,6 +55,8 @@ from .const import (
SourceType,
)
_LOGGER = logging.getLogger(__name__)
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
@@ -164,11 +169,35 @@ class BaseTrackerEntity(Entity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_source_type: SourceType
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if "battery_level" in cls.__dict__:
if cls.__module__.startswith("homeassistant.components."):
# Don't ask users to report issue for built in integrations,
# they already have issues opened on them.
return
report_issue = async_suggest_report_issue(
async_get_hass_or_none(), module=cls.__module__
)
_LOGGER.warning(
(
"%s::%s is overriding the deprecated battery_level property on "
"a subclass of BaseTrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
cls.__module__,
cls.__name__,
report_issue,
)
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
The property is deprecated and will be removed in Home Assistant 2027.7.
"""
return None
@@ -212,13 +241,38 @@ class TrackerEntity(
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
# _attr_location_name is deprecated and will be removed in Home Assistant 2027.7
_attr_location_name: str | None = None
_attr_longitude: float | None = None
_attr_source_type: SourceType = SourceType.GPS
__active_zone: State | None = None
# If we reported setting deprecated _attr_location_name
__deprecated_attr_location_name_reported = False
__in_zones: list[str] | None = None
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if "location_name" in cls.__dict__:
if cls.__module__.startswith("homeassistant.components."):
# Don't ask users to report issue for built in integrations,
# they already have issues opened on them.
return
report_issue = async_suggest_report_issue(
async_get_hass_or_none(), module=cls.__module__
)
_LOGGER.warning(
(
"%s::%s is overriding the deprecated location_name property on "
"an instance of TrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
cls.__module__,
cls.__name__,
report_issue,
)
@cached_property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
@@ -249,7 +303,32 @@ class TrackerEntity(
@cached_property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
"""Return a location name for the current location of the device.
The property is deprecated and will be removed in Home Assistant 2027.7.
"""
if (location_name := self._attr_location_name) is not None:
if (
not self.__deprecated_attr_location_name_reported
and not self.__class__.__module__.startswith(
"homeassistant.components."
)
):
report_issue = async_suggest_report_issue(
self.hass, module=self.__class__.__module__
)
_LOGGER.warning(
(
"%s::%s is setting the deprecated _attr_location_name attribute "
"on an instance of TrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
self.__class__.__module__,
self.__class__.__name__,
report_issue,
)
self.__deprecated_attr_location_name_reported = True
return location_name
return self._attr_location_name
@cached_property
@@ -50,7 +50,7 @@ class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
"""Update Duck DNS."""
retry_after = BACKOFF_INTERVALS[
min(self.failed, len(BACKOFF_INTERVALS))
min(self.failed, len(BACKOFF_INTERVALS) - 1)
].total_seconds()
try:
+12 -2
View File
@@ -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"
@@ -284,6 +284,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
UpdateDeviceClass, static_info.device_class
)
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
"""Return True if latest_version is newer than installed_version.
ESPHome project versions can carry a build suffix (e.g.
2025.11.5_c51f7548) that AwesomeVersion cannot parse. Without stripping
it the base comparison raises and the entity is forced on for every
build mismatch. Drop the suffix so the versions compare cleanly and we
only report genuinely newer firmware.
"""
return super().version_is_newer(
latest_version.partition("_")[0], installed_version.partition("_")[0]
)
@property
@esphome_state_property
def installed_version(self) -> str:
@@ -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"
+5 -1
View File
@@ -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)
+6
View File
@@ -1,6 +1,12 @@
{
"entity": {
"light": {
"hue_grouped_light": {
"default": "mdi:lightbulb-group",
"state": {
"off": "mdi:lightbulb-group-off"
}
},
"hue_light": {
"state_attributes": {
"effect": {
-1
View File
@@ -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,
)
+10 -6
View File
@@ -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
+1 -1
View File
@@ -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",
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["iometer==0.4.0"],
"requirements": ["iometer==1.0.1"],
"zeroconf": ["_iometer._tcp.local."]
}
+1 -1
View File
@@ -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
+1 -4
View File
@@ -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()
@@ -556,4 +556,48 @@ DISCOVERY_SCHEMAS = [
featuremap_contains=clusters.Thermostat.Bitmaps.Feature.kOccupancy,
allow_multi=True,
),
# GeneralDiagnostics active fault sensors
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="GeneralDiagnosticsActiveHardwareFaults",
translation_key="active_hardware_faults",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_to_ha=bool,
),
entity_class=MatterBinarySensor,
required_attributes=(
clusters.GeneralDiagnostics.Attributes.ActiveHardwareFaults,
),
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="GeneralDiagnosticsActiveRadioFaults",
translation_key="active_radio_faults",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_to_ha=bool,
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.GeneralDiagnostics.Attributes.ActiveRadioFaults,),
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="GeneralDiagnosticsActiveNetworkFaults",
translation_key="active_network_faults",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_to_ha=bool,
),
entity_class=MatterBinarySensor,
required_attributes=(
clusters.GeneralDiagnostics.Attributes.ActiveNetworkFaults,
),
),
]
+3 -1
View File
@@ -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"
),
)
+8 -1
View File
@@ -457,7 +457,14 @@ class MatterLight(MatterEntity, LightEntity):
self._transitions_disabled = True
LOGGER.warning(
"Detected a device that has been reported to have firmware issues "
"with light transitions. Transitions will be disabled for this light"
"with light transitions. Transitions will be disabled for this "
"light: %s %s (vendor_id: %s, product_id: %s, hw: %s, sw: %s)",
device_info.vendorName,
device_info.productName,
device_info.vendorID,
device_info.productID,
device_info.hardwareVersionString,
device_info.softwareVersionString,
)
+66
View File
@@ -137,6 +137,17 @@ RVC_OPERATIONAL_STATE_ERROR_MAP = {
_rvc_err.kNavigationSensorObscured: ("navigation_sensor_obscured"),
}
BOOT_REASON_MAP = {
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnspecified: "unspecified",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kPowerOnReboot: "power_on_reboot",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kBrownOutReset: "brown_out_reset",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareWatchdogReset: "software_watchdog_reset",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kHardwareWatchdogReset: "hardware_watchdog_reset",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareUpdateCompleted: "software_update_completed",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareReset: "software_reset",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnknownEnumValue: None,
}
BOOST_STATE_MAP = {
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kInactive: "inactive",
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: "active",
@@ -428,6 +439,19 @@ DISCOVERY_SCHEMAS = [
),
allow_multi=True, # also used for climate entity
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SoilMoistureSensor",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.MOISTURE,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(
clusters.SoilMeasurement.Attributes.SoilMoistureMeasuredValue,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
@@ -1575,4 +1599,46 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.DoorLock.Attributes.DoorClosedEvents,),
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
),
# GeneralDiagnostics cluster sensors
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="GeneralDiagnosticsRebootCount",
translation_key="reboot_count",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
),
entity_class=MatterSensor,
required_attributes=(clusters.GeneralDiagnostics.Attributes.RebootCount,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="GeneralDiagnosticsUpTime",
translation_key="uptime",
device_class=SensorDeviceClass.UPTIME,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_to_ha=lambda uptime: dt_util.utcnow() - timedelta(seconds=uptime),
),
entity_class=MatterSensor,
required_attributes=(clusters.GeneralDiagnostics.Attributes.UpTime,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="GeneralDiagnosticsBootReason",
translation_key="boot_reason",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
options=[
reason for reason in BOOT_REASON_MAP.values() if reason is not None
],
device_to_ha=BOOT_REASON_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.GeneralDiagnostics.Attributes.BootReason,),
),
]
@@ -47,6 +47,15 @@
},
"entity": {
"binary_sensor": {
"active_hardware_faults": {
"name": "Hardware faults"
},
"active_network_faults": {
"name": "Network faults"
},
"active_radio_faults": {
"name": "Radio faults"
},
"actuator": {
"name": "Actuator"
},
@@ -408,6 +417,18 @@
"battery_voltage": {
"name": "Battery voltage"
},
"boot_reason": {
"name": "Boot reason",
"state": {
"brown_out_reset": "Brownout reset",
"hardware_watchdog_reset": "Hardware watchdog reset",
"power_on_reboot": "Power-on reboot",
"software_reset": "Software reset",
"software_update_completed": "Software update completed",
"software_watchdog_reset": "Software watchdog reset",
"unspecified": "Unspecified"
}
},
"contamination_state": {
"name": "Contamination state",
"state": {
@@ -576,6 +597,9 @@
"reactive_current": {
"name": "Reactive current"
},
"reboot_count": {
"name": "Reboot count"
},
"rms_current": {
"name": "Effective current"
},
@@ -600,6 +624,9 @@
"medium": "[%key:common::state::medium%]"
}
},
"uptime": {
"name": "Uptime"
},
"valve_position": {
"name": "Valve position"
},
@@ -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"
},
+6 -7
View File
@@ -5457,7 +5457,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 +5465,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 +5561,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,
+1 -1
View File
@@ -1140,7 +1140,7 @@
},
"step": {
"confirm": {
"description": "Home Assistant is migrating 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 try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.",
"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"
}
}
+4 -6
View File
@@ -271,7 +271,7 @@ class MqttValve(MqttEntity, ValveEntity):
self._range, float(position_payload)
)
except ValueError:
_LOGGER.warning(
_LOGGER.debug(
"Ignoring non numeric payload '%s' received on topic '%s'",
position_payload,
msg.topic,
@@ -279,9 +279,9 @@ class MqttValve(MqttEntity, ValveEntity):
else:
percentage_payload = min(max(percentage_payload, 0), 100)
self._attr_current_valve_position = percentage_payload
# Reset closing and opening if the valve is fully opened or fully closed
if state is None and percentage_payload in (0, 100):
state = RESET_CLOSING_OPENING
# Reset opening/closing when a position update is received
# without an explicit opening/closing transitional state.
state = state or RESET_CLOSING_OPENING
position_set = True
if state_payload and state is None and not position_set:
_LOGGER.warning(
@@ -291,8 +291,6 @@ class MqttValve(MqttEntity, ValveEntity):
state_payload,
)
return
if state is None:
return
self._update_state(state)
@callback
+5
View File
@@ -52,6 +52,11 @@ class OMIEPriceSensor(CoordinatorEntity[OMIECoordinator], SensorEntity):
self._attr_unique_id = pyomie_series_name
self._pyomie_series_name = pyomie_series_name
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
self._handle_coordinator_update()
@callback
def _handle_coordinator_update(self) -> None:
"""Update this sensor's state from the coordinator results."""
+1 -1
View File
@@ -14,7 +14,7 @@
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": [
"onvif-zeep-async==4.1.0",
"onvif-zeep-async==4.1.1",
"onvif_parsers==2.3.0",
"WSDiscovery==2.1.2"
]
@@ -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
}
@@ -1,6 +1,12 @@
"""The OVHcloud AI Endpoints integration."""
from openai import AsyncOpenAI, AuthenticationError, BadRequestError, OpenAIError
from openai import (
AsyncOpenAI,
AuthenticationError,
BadRequestError,
OpenAIError,
PermissionDeniedError,
)
from openai.types.chat import ChatCompletionUserMessageParam
from homeassistant.config_entries import ConfigEntry
@@ -52,7 +58,7 @@ async def async_setup_entry(
try:
await _validate_api_key(client)
except AuthenticationError as err:
except (AuthenticationError, PermissionDeniedError) as err:
raise ConfigEntryAuthFailed(err) from err
except OpenAIError as err:
raise ConfigEntryNotReady(err) from err
@@ -1,9 +1,10 @@
"""Config flow for the OVHcloud AI Endpoints integration."""
from collections.abc import Mapping
import logging
from typing import Any
from openai import AsyncOpenAI, AuthenticationError, OpenAIError
from openai import AsyncOpenAI, AuthenticationError, OpenAIError, PermissionDeniedError
import voluptuous as vol
from homeassistant.config_entries import (
@@ -30,6 +31,8 @@ from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
_LOGGER = logging.getLogger(__name__)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OVHcloud AI Endpoints."""
@@ -55,7 +58,7 @@ class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
client = _create_client(self.hass, user_input[CONF_API_KEY])
try:
await _validate_api_key(client)
except AuthenticationError:
except AuthenticationError, PermissionDeniedError:
errors["base"] = "invalid_auth"
except OpenAIError:
errors["base"] = "cannot_connect"
@@ -77,6 +80,39 @@ class OVHcloudAIEndpointsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
if user_input is not None:
client = _create_client(self.hass, user_input[CONF_API_KEY])
try:
await _validate_api_key(client)
except AuthenticationError, PermissionDeniedError:
errors["base"] = "invalid_auth"
except OpenAIError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)
class ConversationFlowHandler(ConfigSubentryFlow):
"""Handle conversation subentry flow."""
@@ -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
reauthentication-flow: 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
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +10,15 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::ovhcloud_ai_endpoints::config::step::user::data_description::api_key%]"
},
"description": "The OVHcloud AI Endpoints API key is no longer valid. Please enter a new one."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
+13 -6
View File
@@ -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,
+2 -2
View File
@@ -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:
+38 -18
View File
@@ -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(
+12 -3
View File
@@ -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",
+15 -13
View File
@@ -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)
+26 -16
View File
@@ -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"
},
+5 -1
View File
@@ -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 -4
View File
@@ -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,
+4
View File
@@ -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"
+1 -1
View File
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["wiim.sdk", "async_upnp_client"],
"quality_scale": "bronze",
"requirements": ["wiim==0.1.2"],
"requirements": ["wiim==0.1.4"],
"zeroconf": ["_linkplay._tcp.local."]
}
@@ -349,15 +349,12 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
sdk_status_str,
)
else:
self._device.playing_status = sdk_status
if sdk_status == SDKPlayingStatus.STOPPED:
LOGGER.debug(
"Device %s: TransportState is STOPPED."
" Resetting media position and metadata",
self.entity_id,
)
self._device.current_position = 0
self._device.current_track_duration = 0
self._attr_media_position_updated_at = None
self._attr_media_duration = None
self._attr_media_position = None
@@ -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,
)
+1 -1
View File
@@ -14,5 +14,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
}
@@ -63,7 +63,7 @@ class MusicCastDeviceEntity(MusicCastEntity):
},
manufacturer=BRAND,
model=self.coordinator.data.model_name,
sw_version=self.coordinator.data.system_version,
sw_version=str(self.coordinator.data.system_version),
)
if self._zone_id == DEFAULT_ZONE:

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