Compare commits

..

52 Commits

Author SHA1 Message Date
Mike Degatano ef1bbc4e0b Fixes from feedback 2026-05-28 17:38:09 +00:00
Mike Degatano 849cd14233 remove entry.async_on_unload calls 2026-05-28 17:38:09 +00:00
Mike Degatano 4668800cd3 Remove unload entry support from analytics 2026-05-28 17:38:08 +00:00
Mike Degatano 5f495d725b Fixes from feedback 2026-05-28 17:38:07 +00:00
Mike Degatano 9e1741067a Fix prek errors 2026-05-28 17:38:06 +00:00
Mike Degatano a1da79314a Fix config entry from feedback
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 17:38:06 +00:00
Mike Degatano 6e1c6393aa Hassfest changes for config flow 2026-05-28 17:38:05 +00:00
Mike Degatano 4fe38d3f51 Migrate analytics integration to config entry setup
- Add config_flow.py with a minimal system config flow
- Split async_setup (lightweight: YAML config, labs feature, discovery
  flow, websocket/HTTP registration) from async_setup_entry (heavy:
  Analytics init, load, scheduling, listeners)
- Add async_unload_entry that cancels scheduled analytics tasks
- Thread snapshots_url from YAML through hass.data so it reaches
  async_setup_entry without persisting to config entry data, keeping
  the option as a hidden developer-only YAML setting for now
- Catch HassioNotReadyError from Analytics.load and raise
  ConfigEntryNotReady so setup is retried when Supervisor is not yet
  ready
- Register websocket commands and HTTP view in async_setup so they
  survive entry reload; guard both handlers with ERR_NOT_FOUND when
  the entry is not loaded
- Replace async_listen_once(EVENT_HOMEASSISTANT_STARTED) with
  async_at_started so the schedule starts immediately on reload when
  HA is already running
- Add cancel_scheduled() to Analytics class
- Update stale comments in Analytics.load and send_analytics
- Add supervisor_not_ready exception translation key
- Add tests for: ConfigEntryNotReady on supervisor failure, schedule
  fires and sends analytics, unload stops the schedule, websocket
  error when entry not loaded, snapshots_url routes to custom URL

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 17:38:05 +00: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
156 changed files with 16843 additions and 939 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
+2 -2
View File
@@ -530,7 +530,7 @@ jobs:
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)
+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"
]
}
@@ -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={})
@@ -14,5 +14,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).",
@@ -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"
]
}
@@ -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",
@@ -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"
@@ -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)
+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
@@ -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"
},
+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"
]
@@ -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."""
@@ -44,7 +44,7 @@ rules:
status: exempt
comment: the integration only integrates stateless entities
parallel-updates: todo
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold
@@ -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%]"
@@ -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
@@ -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 = {
+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
+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:
@@ -80,31 +80,31 @@ class ZHAAlarmControlPanel(ZHAEntity, AlarmControlPanelEntity):
"""Whether the code is required for arm actions."""
return self.entity_data.entity.code_arm_required
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
await self.entity_data.entity.async_alarm_disarm(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self.entity_data.entity.async_alarm_arm_home(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.entity_data.entity.async_alarm_arm_away(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self.entity_data.entity.async_alarm_arm_night(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send alarm trigger command."""
await self.entity_data.entity.async_alarm_trigger(code)
+1 -1
View File
@@ -52,7 +52,7 @@ class ZHAButton(ZHAEntity, ButtonEntity):
self.entity_data.entity.info_object.device_class
)
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_press(self) -> None:
"""Send out a update command."""
await self.entity_data.entity.async_press()
+4 -4
View File
@@ -203,25 +203,25 @@ class Thermostat(ZHAEntity, ClimateEntity):
)
super()._handle_entity_events(event)
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
await self.entity_data.entity.async_set_fan_mode(fan_mode=fan_mode)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode."""
await self.entity_data.entity.async_set_hvac_mode(hvac_mode=hvac_mode)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
await self.entity_data.entity.async_set_temperature(
+4
View File
@@ -75,3 +75,7 @@ MFG_CLUSTER_ID_START = 0xFC00
ZHA_ALARM_OPTIONS = "zha_alarm_options"
ZHA_OPTIONS = "zha_options"
# Dispatcher signal carrying device reconfigure progress events (bind result,
# attribute reporting result, configure complete) to the websocket subscriber.
SIGNAL_DEVICE_RECONFIGURE_EVENT = "zha_device_reconfigure_event"
+8 -8
View File
@@ -122,31 +122,31 @@ class ZhaCover(ZHAEntity, CoverEntity):
"""Return the current tilt position of the cover."""
return self.entity_data.entity.current_cover_tilt_position
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.entity_data.entity.async_open_cover()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
await self.entity_data.entity.async_open_cover_tilt()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self.entity_data.entity.async_close_cover()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
await self.entity_data.entity.async_close_cover_tilt()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
await self.entity_data.entity.async_set_cover_position(
@@ -154,7 +154,7 @@ class ZhaCover(ZHAEntity, CoverEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
await self.entity_data.entity.async_set_cover_tilt_position(
@@ -162,13 +162,13 @@ class ZhaCover(ZHAEntity, CoverEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.entity_data.entity.async_stop_cover()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the cover tilt."""
await self.entity_data.entity.async_stop_cover_tilt()
+77 -93
View File
@@ -3,36 +3,26 @@
from typing import Any
import voluptuous as vol
from zha.exceptions import ZHAException
from zha.zigbee.cluster_handlers.const import (
CLUSTER_HANDLER_IAS_WD,
CLUSTER_HANDLER_INOVELLI,
)
from zha.zigbee.cluster_handlers.manufacturerspecific import (
AllLEDEffectType,
SingleLEDEffectType,
)
from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType
from zigpy.zcl.clusters.security import IasWd
from homeassistant.components.device_automation import InvalidDeviceAutomationConfig
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .const import DOMAIN
from .helpers import async_get_zha_device_proxy
from .helpers import async_get_zha_device_proxy, convert_zha_error_to_ha_error
from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN
# mypy: disallow-any-generics
INOVELLI_CLUSTER_ID = 0xFC31
ACTION_SQUAWK = "squawk"
ACTION_WARN = "warn"
ATTR_DATA = "data"
ATTR_IEEE = "ieee"
CONF_ZHA_ACTION_TYPE = "zha_action_type"
ZHA_ACTION_TYPE_SERVICE_CALL = "service_call"
ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND = "cluster_handler_command"
INOVELLI_ALL_LED_EFFECT = "issue_all_led_effect"
INOVELLI_INDIVIDUAL_LED_EFFECT = "issue_individual_led_effect"
@@ -73,24 +63,18 @@ ACTION_SCHEMA = vol.Any(
DEFAULT_ACTION_SCHEMA,
)
DEVICE_ACTIONS = {
CLUSTER_HANDLER_IAS_WD: [
# Maps a cluster_id the device must expose to the available actions.
DEVICE_ACTIONS_BY_CLUSTER_ID: dict[int, list[dict[str, str]]] = {
IasWd.cluster_id: [
{CONF_TYPE: ACTION_SQUAWK, CONF_DOMAIN: DOMAIN},
{CONF_TYPE: ACTION_WARN, CONF_DOMAIN: DOMAIN},
],
CLUSTER_HANDLER_INOVELLI: [
INOVELLI_CLUSTER_ID: [
{CONF_TYPE: INOVELLI_ALL_LED_EFFECT, CONF_DOMAIN: DOMAIN},
{CONF_TYPE: INOVELLI_INDIVIDUAL_LED_EFFECT, CONF_DOMAIN: DOMAIN},
],
}
DEVICE_ACTION_TYPES = {
ACTION_SQUAWK: ZHA_ACTION_TYPE_SERVICE_CALL,
ACTION_WARN: ZHA_ACTION_TYPE_SERVICE_CALL,
INOVELLI_ALL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND,
INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND,
}
DEVICE_ACTION_SCHEMAS = {
INOVELLI_ALL_LED_EFFECT: vol.Schema(
{
@@ -116,11 +100,6 @@ SERVICE_NAMES = {
ACTION_WARN: SERVICE_WARNING_DEVICE_WARN,
}
CLUSTER_HANDLER_MAPPINGS = {
INOVELLI_ALL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI,
INOVELLI_INDIVIDUAL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI,
}
async def async_call_action_from_config(
hass: HomeAssistant,
@@ -129,9 +108,9 @@ async def async_call_action_from_config(
context: Context | None,
) -> None:
"""Perform an action based on configuration."""
await ZHA_ACTION_TYPES[DEVICE_ACTION_TYPES[config[CONF_TYPE]]](
hass, config, variables, context
)
action_type = config[CONF_TYPE]
handler = ACTION_HANDLERS[action_type]
await handler(hass, config, context)
async def async_validate_action_config(
@@ -150,19 +129,18 @@ async def async_get_actions(
zha_device = async_get_zha_device_proxy(hass, device_id).device
except KeyError, AttributeError:
return []
cluster_handlers = [
ch.name
for endpoint in zha_device.endpoints.values()
for ch in endpoint.claimed_cluster_handlers.values()
]
actions = [
action
for cluster_handler, cluster_handler_actions in DEVICE_ACTIONS.items()
for action in cluster_handler_actions
if cluster_handler in cluster_handlers
]
for action in actions:
action[CONF_DEVICE_ID] = device_id
cluster_ids = {
cluster_id
for ep_id, endpoint in zha_device.device.endpoints.items()
if ep_id != 0
for cluster_id in endpoint.in_clusters
}
actions: list[dict[str, str]] = []
for required_cluster_id, cluster_actions in DEVICE_ACTIONS_BY_CLUSTER_ID.items():
if required_cluster_id in cluster_ids:
actions.extend(
{**action, CONF_DEVICE_ID: device_id} for action in cluster_actions
)
return actions
@@ -175,69 +153,75 @@ async def async_get_action_capabilities(
return {"extra_fields": fields}
async def _execute_service_based_action(
async def _execute_siren_service(
hass: HomeAssistant,
config: dict[str, Any],
variables: TemplateVarsType,
context: Context | None,
) -> None:
action_type = config[CONF_TYPE]
service_name = SERVICE_NAMES[action_type]
try:
zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device
except KeyError, AttributeError:
return
service_data = {ATTR_IEEE: str(zha_device.ieee)}
await hass.services.async_call(
DOMAIN, service_name, service_data, blocking=True, context=context
DOMAIN,
SERVICE_NAMES[config[CONF_TYPE]],
{ATTR_IEEE: str(zha_device.ieee)},
blocking=True,
context=context,
)
async def _execute_cluster_handler_command_based_action(
hass: HomeAssistant,
config: dict[str, Any],
variables: TemplateVarsType,
context: Context | None,
) -> None:
action_type = config[CONF_TYPE]
cluster_handler_name = CLUSTER_HANDLER_MAPPINGS[action_type]
def _find_inovelli_cluster(hass: HomeAssistant, config: dict[str, Any]) -> Any:
try:
zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device
except KeyError, AttributeError:
return
action_cluster_handler = None
for endpoint in zha_device.endpoints.values():
for cluster_handler in endpoint.all_cluster_handlers.values():
if cluster_handler.name == cluster_handler_name:
action_cluster_handler = cluster_handler
break
if action_cluster_handler is None:
except (KeyError, AttributeError) as err:
raise InvalidDeviceAutomationConfig(
f"Unable to execute cluster handler action -"
f" cluster handler: {cluster_handler_name} action:"
f" {action_type}"
)
if not hasattr(action_cluster_handler, action_type):
raise InvalidDeviceAutomationConfig(
f"Unable to execute cluster handler -"
f" cluster handler: {cluster_handler_name} action:"
f" {action_type}"
)
f"ZHA device {config[CONF_DEVICE_ID]} not found"
) from err
try:
await getattr(action_cluster_handler, action_type)(**config)
except ZHAException as err:
raise HomeAssistantError(err) from err
return zha_device.device.find_cluster(cluster_id=INOVELLI_CLUSTER_ID)
except ValueError as err:
raise InvalidDeviceAutomationConfig(
f"Device does not expose Inovelli cluster 0x{INOVELLI_CLUSTER_ID:04x}"
) from err
ZHA_ACTION_TYPES = {
ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action,
ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND: (
_execute_cluster_handler_command_based_action
),
async def _execute_inovelli_all_led_effect(
hass: HomeAssistant,
config: dict[str, Any],
context: Context | None,
) -> None:
cluster = _find_inovelli_cluster(hass, config)
async with convert_zha_error_to_ha_error():
await cluster.led_effect(
led_effect=config["effect_type"],
led_color=config["color"],
led_level=config["level"],
led_duration=config["duration"],
)
async def _execute_inovelli_individual_led_effect(
hass: HomeAssistant,
config: dict[str, Any],
context: Context | None,
) -> None:
cluster = _find_inovelli_cluster(hass, config)
async with convert_zha_error_to_ha_error():
await cluster.individual_led_effect(
led_effect=config["effect_type"],
led_color=config["color"],
led_level=config["level"],
led_duration=config["duration"],
led_number=config["led_number"],
)
ACTION_HANDLERS = {
ACTION_SQUAWK: _execute_siren_service,
ACTION_WARN: _execute_siren_service,
INOVELLI_ALL_LED_EFFECT: _execute_inovelli_all_led_effect,
INOVELLI_INDIVIDUAL_LED_EFFECT: _execute_inovelli_individual_led_effect,
}
+1 -1
View File
@@ -206,7 +206,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
await super().async_will_remove_from_hass()
self.remove_future.set_result(True)
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_update(self) -> None:
"""Update the entity."""
await self.entity_data.entity.async_update()
+4 -4
View File
@@ -92,7 +92,7 @@ class ZhaFan(FanEntity, ZHAEntity):
"""Return the number of speeds the fan supports."""
return self.entity_data.entity.speed_count
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_on(
self,
percentage: int | None = None,
@@ -105,19 +105,19 @@ class ZhaFan(FanEntity, ZHAEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_data.entity.async_turn_off()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
await self.entity_data.entity.async_set_percentage(percentage=percentage)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode for the fan."""
await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode)
+63 -56
View File
@@ -2,23 +2,23 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Coroutine, Mapping
from collections.abc import AsyncGenerator, Callable, Mapping
from contextlib import asynccontextmanager
import copy
import dataclasses
import enum
import functools
import itertools
import logging
import queue
import re
import time
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, cast
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from zoneinfo import ZoneInfo
import voluptuous as vol
from zha.application import Platform as ZhaPlatform
from zha.application.const import (
ATTR_CLUSTER_ID,
ATTR_DEVICE_IEEE,
ATTR_TYPE,
ATTR_UNIQUE_ID,
@@ -28,11 +28,6 @@ from zha.application.const import (
CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS,
UNKNOWN_MANUFACTURER,
UNKNOWN_MODEL,
ZHA_CLUSTER_HANDLER_CFG_DONE,
ZHA_CLUSTER_HANDLER_MSG,
ZHA_CLUSTER_HANDLER_MSG_BIND,
ZHA_CLUSTER_HANDLER_MSG_CFG_RPT,
ZHA_CLUSTER_HANDLER_MSG_DATA,
ZHA_EVENT,
ZHA_GW_MSG,
ZHA_GW_MSG_DEVICE_FULL_INIT,
@@ -71,10 +66,11 @@ from zha.application.platforms import GroupEntity, PlatformEntity
from zha.event import EventBase
from zha.exceptions import ZHAException
from zha.mixins import LogMixin
from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent
from zha.zigbee.device import (
ClusterHandlerConfigurationComplete,
ClusterBindEvent,
ClusterConfigureReportingEvent,
Device,
DeviceConfiguredEvent,
DeviceEntityAddedEvent,
DeviceEntityRemovedEvent,
DeviceFirmwareInfoUpdatedEvent,
@@ -126,9 +122,7 @@ from homeassistant.util.logging import HomeAssistantQueueHandler
from .const import (
ATTR_ACTIVE_COORDINATOR,
ATTR_ATTRIBUTES,
ATTR_AVAILABLE,
ATTR_CLUSTER_NAME,
ATTR_DEVICE_TYPE,
ATTR_ENDPOINT_NAMES,
ATTR_EXPOSES_FEATURES,
@@ -144,7 +138,6 @@ from .const import (
ATTR_ROUTES,
ATTR_RSSI,
ATTR_SIGNATURE,
ATTR_SUCCESS,
CONF_ALARM_ARM_REQUIRES_CODE,
CONF_ALARM_FAILED_TRIES,
CONF_ALARM_MASTER_CODE,
@@ -168,6 +161,7 @@ from .const import (
DEFAULT_DATABASE_NAME,
DEVICE_PAIRING_STATUS,
DOMAIN,
SIGNAL_DEVICE_RECONFIGURE_EVENT,
ZHA_ALARM_OPTIONS,
ZHA_OPTIONS,
)
@@ -450,50 +444,46 @@ class ZHADeviceProxy(EventBase):
)
@callback
def handle_zha_channel_configure_reporting(
def handle_zha_cluster_bind(self, event: ClusterBindEvent) -> None:
"""Forward a cluster bind result to the reconfigure websocket."""
async_dispatcher_send(
self.gateway_proxy.hass,
SIGNAL_DEVICE_RECONFIGURE_EVENT,
{
"type": "zha_channel_bind",
"zha_channel_msg_data": {
"cluster_name": event.cluster_name,
"cluster_id": event.cluster_id,
"success": event.success,
},
},
)
@callback
def handle_zha_cluster_configure_reporting(
self, event: ClusterConfigureReportingEvent
) -> None:
"""Handle a ZHA cluster configure reporting event."""
"""Forward a cluster reporting-configured result to the reconfigure websocket."""
async_dispatcher_send(
self.gateway_proxy.hass,
ZHA_CLUSTER_HANDLER_MSG,
SIGNAL_DEVICE_RECONFIGURE_EVENT,
{
ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_CFG_RPT,
ZHA_CLUSTER_HANDLER_MSG_DATA: {
ATTR_CLUSTER_NAME: event.cluster_name,
ATTR_CLUSTER_ID: event.cluster_id,
ATTR_ATTRIBUTES: event.attributes,
"type": "zha_channel_configure_reporting",
"zha_channel_msg_data": {
"cluster_name": event.cluster_name,
"cluster_id": event.cluster_id,
"attributes": event.attributes,
},
},
)
@callback
def handle_zha_channel_cfg_done(
self, event: ClusterHandlerConfigurationComplete
) -> None:
"""Handle a ZHA cluster configure reporting event."""
def handle_zha_device_configured(self, event: DeviceConfiguredEvent) -> None:
"""Forward the device configuration-complete signal to the reconfigure websocket."""
async_dispatcher_send(
self.gateway_proxy.hass,
ZHA_CLUSTER_HANDLER_MSG,
{
ATTR_TYPE: ZHA_CLUSTER_HANDLER_CFG_DONE,
},
)
@callback
def handle_zha_channel_bind(self, event: ClusterBindEvent) -> None:
"""Handle a ZHA cluster bind event."""
async_dispatcher_send(
self.gateway_proxy.hass,
ZHA_CLUSTER_HANDLER_MSG,
{
ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND,
ZHA_CLUSTER_HANDLER_MSG_DATA: {
ATTR_CLUSTER_NAME: event.cluster_name,
ATTR_CLUSTER_ID: event.cluster_id,
ATTR_SUCCESS: event.success,
},
},
SIGNAL_DEVICE_RECONFIGURE_EVENT,
{"type": "zha_channel_cfg_done"},
)
@callback
@@ -501,6 +491,9 @@ class ZHADeviceProxy(EventBase):
self, event: DeviceEntityAddedEvent
) -> None:
"""Handle a new entity being added to a device at runtime."""
if event.platform is ZhaPlatform.VIRTUAL:
return
key = (event.platform, event.unique_id)
if (entity := self.device.platform_entities.get(key)) is None:
return
@@ -515,6 +508,9 @@ class ZHADeviceProxy(EventBase):
self, event: DeviceEntityRemovedEvent
) -> None:
"""Handle an entity being removed from a device at runtime."""
if event.platform is ZhaPlatform.VIRTUAL:
return
if not event.remove:
# Soft remove: signal the entity to unload; registry entry stays
async_dispatcher_send(
@@ -911,6 +907,9 @@ class ZHAGatewayProxy(EventBase):
if isinstance(proxy_object, ZHADeviceProxy):
for entity in proxy_object.device.platform_entities.values():
if entity.PLATFORM is ZhaPlatform.VIRTUAL:
continue
ha_zha_data.platforms[Platform(entity.PLATFORM)].append(
EntityData(
entity=entity, device_proxy=proxy_object, group_proxy=None
@@ -918,6 +917,9 @@ class ZHAGatewayProxy(EventBase):
)
else:
for entity in proxy_object.group.group_entities.values():
if entity.PLATFORM is ZhaPlatform.VIRTUAL:
continue
ha_zha_data.platforms[Platform(entity.PLATFORM)].append(
EntityData(
entity=entity,
@@ -1386,19 +1388,24 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData:
)
def convert_zha_error_to_ha_error[**_P, _EntityT: ZHAEntity](
func: Callable[Concatenate[_EntityT, _P], Awaitable[None]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
@asynccontextmanager
async def convert_zha_error_to_ha_error() -> AsyncGenerator[None]:
"""Decorate ZHA commands and re-raises ZHAException as HomeAssistantError."""
try:
yield
except TimeoutError as exc:
raise HomeAssistantError(
"Failed to send request: device did not respond"
) from exc
except zigpy.exceptions.ZigbeeException as exc:
message = "Failed to send request"
@functools.wraps(func)
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
return await func(self, *args, **kwargs)
except ZHAException as err:
raise HomeAssistantError(err) from err
if str(exc):
message = f"{message}: {exc}"
return handler
raise HomeAssistantError(message) from exc
except ZHAException as err:
raise HomeAssistantError(err) from err
def exclude_none_values(obj: Mapping[str, Any]) -> dict[str, Any]:
+2 -2
View File
@@ -171,7 +171,7 @@ class Light(LightEntity, ZHAEntity):
"""Return the current effect."""
return self.entity_data.entity.effect
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
color_temp = (
@@ -189,7 +189,7 @@ class Light(LightEntity, ZHAEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_data.entity.async_turn_off(
+6 -6
View File
@@ -94,19 +94,19 @@ class ZhaDoorLock(ZHAEntity, LockEntity):
"""Return true if entity is locked."""
return self.entity_data.entity.is_locked
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
await self.entity_data.entity.async_lock()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock."""
await self.entity_data.entity.async_unlock()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None:
"""Set the user_code to index X on the lock."""
await self.entity_data.entity.async_set_lock_user_code(
@@ -114,19 +114,19 @@ class ZhaDoorLock(ZHAEntity, LockEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_enable_lock_user_code(self, code_slot: int) -> None:
"""Enable user_code at index X on the lock."""
await self.entity_data.entity.async_enable_lock_user_code(code_slot=code_slot)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_disable_lock_user_code(self, code_slot: int) -> None:
"""Disable user_code at index X on the lock."""
await self.entity_data.entity.async_disable_lock_user_code(code_slot=code_slot)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_clear_lock_user_code(self, code_slot: int) -> None:
"""Clear the user_code at index X on the lock."""
await self.entity_data.entity.async_clear_lock_user_code(code_slot=code_slot)
+1 -1
View File
@@ -23,7 +23,7 @@
"universal_silabs_flasher",
"serialx"
],
"requirements": ["zha==1.3.1"],
"requirements": ["zha==1.4.0"],
"usb": [
{
"description": "*2652*",
+1 -1
View File
@@ -78,7 +78,7 @@ class ZhaNumber(ZHAEntity, RestoreNumber):
"""Return the unit the value is expressed in."""
return self.entity_data.entity.native_unit_of_measurement
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_native_value(self, value: float) -> None:
"""Update the current value from HA."""
await self.entity_data.entity.async_set_native_value(value=value)
+1 -1
View File
@@ -58,7 +58,7 @@ class ZHAEnumSelectEntity(ZHAEntity, SelectEntity):
"""Return the selected entity option to represent the entity state."""
return self.entity_data.entity.current_option
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_data.entity.async_select_option(option=option)
+11 -16
View File
@@ -3,15 +3,10 @@
import functools
from typing import Any
from zha.application.const import (
WARNING_DEVICE_MODE_BURGLAR,
WARNING_DEVICE_MODE_EMERGENCY,
WARNING_DEVICE_MODE_EMERGENCY_PANIC,
WARNING_DEVICE_MODE_FIRE,
WARNING_DEVICE_MODE_FIRE_PANIC,
WARNING_DEVICE_MODE_POLICE_PANIC,
from zha.application.platforms.siren import (
SirenEntityFeature as ZHASirenEntityFeature,
WarningMode,
)
from zha.application.platforms.siren import SirenEntityFeature as ZHASirenEntityFeature
from homeassistant.components.siren import (
ATTR_DURATION,
@@ -59,12 +54,12 @@ class ZHASiren(ZHAEntity, SirenEntity):
"""Representation of a ZHA siren."""
_attr_available_tones: list[int | str] | dict[int, str] | None = {
WARNING_DEVICE_MODE_BURGLAR: "Burglar",
WARNING_DEVICE_MODE_FIRE: "Fire",
WARNING_DEVICE_MODE_EMERGENCY: "Emergency",
WARNING_DEVICE_MODE_POLICE_PANIC: "Police Panic",
WARNING_DEVICE_MODE_FIRE_PANIC: "Fire Panic",
WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic",
WarningMode.Burglar: "Burglar",
WarningMode.Fire: "Fire",
WarningMode.Emergency: "Emergency",
WarningMode.Police_Panic: "Police Panic",
WarningMode.Fire_Panic: "Fire Panic",
WarningMode.Emergency_Panic: "Emergency Panic",
}
def __init__(self, entity_data: EntityData, **kwargs: Any) -> None:
@@ -92,7 +87,7 @@ class ZHASiren(ZHAEntity, SirenEntity):
"""Return True if entity is on."""
return self.entity_data.entity.is_on
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on siren."""
await self.entity_data.entity.async_turn_on(
@@ -102,7 +97,7 @@ class ZHASiren(ZHAEntity, SirenEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off siren."""
await self.entity_data.entity.async_turn_off()
+2 -2
View File
@@ -49,13 +49,13 @@ class Switch(ZHAEntity, SwitchEntity):
"""Return if the switch is on based on the statemachine."""
return self.entity_data.entity.is_on
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.entity_data.entity.async_turn_on()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_data.entity.async_turn_off()
+1 -1
View File
@@ -181,7 +181,7 @@ class ZHAFirmwareUpdateEntity(
return self.entity_data.entity.release_url
# We explicitly convert ZHA exceptions to HA exceptions here so there is no need to
# use the `@convert_zha_error_to_ha_error` decorator.
# use the `@convert_zha_error_to_ha_error()` decorator.
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
+34 -80
View File
@@ -29,12 +29,6 @@ from zha.application.const import (
CLUSTER_COMMANDS_SERVER,
CLUSTER_TYPE_IN,
CLUSTER_TYPE_OUT,
WARNING_DEVICE_MODE_EMERGENCY,
WARNING_DEVICE_SOUND_HIGH,
WARNING_DEVICE_SQUAWK_MODE_ARMED,
WARNING_DEVICE_STROBE_HIGH,
WARNING_DEVICE_STROBE_YES,
ZHA_CLUSTER_HANDLER_MSG,
ZHA_GW_MSG,
)
from zha.application.gateway import Gateway
@@ -44,7 +38,14 @@ from zha.application.helpers import (
get_matched_clusters,
qr_to_install_code,
)
from zha.zigbee.cluster_handlers.const import CLUSTER_HANDLER_IAS_WD
from zha.application.platforms.siren import (
BaseSiren,
SirenLevel,
SquawkMode,
Strobe,
StrobeLevel,
WarningMode,
)
from zha.zigbee.group import GroupMemberReference
import zigpy.backups
from zigpy.config import CONF_DEVICE
@@ -59,7 +60,7 @@ import zigpy.zdo.types as zdo_types
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME
from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME, Platform
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -79,6 +80,7 @@ from .const import (
GROUP_IDS,
GROUP_NAME,
MFG_CLUSTER_ID_START,
SIGNAL_DEVICE_RECONFIGURE_EVENT,
ZHA_ALARM_OPTIONS,
ZHA_OPTIONS,
)
@@ -180,13 +182,13 @@ SERVICE_SCHEMAS: dict[str, VolSchemaType] = {
{
vol.Required(ATTR_IEEE): IEEE_SCHEMA,
vol.Optional(
ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED
ATTR_WARNING_DEVICE_MODE, default=SquawkMode.Armed
): cv.positive_int,
vol.Optional(
ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES
ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe
): cv.positive_int,
vol.Optional(
ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH
ATTR_LEVEL, default=SirenLevel.High_level_sound
): cv.positive_int,
}
),
@@ -194,20 +196,21 @@ SERVICE_SCHEMAS: dict[str, VolSchemaType] = {
{
vol.Required(ATTR_IEEE): IEEE_SCHEMA,
vol.Optional(
ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY
ATTR_WARNING_DEVICE_MODE, default=WarningMode.Emergency
): cv.positive_int,
vol.Optional(
ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES
ATTR_WARNING_DEVICE_STROBE, default=Strobe.Strobe
): cv.positive_int,
vol.Optional(
ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH
ATTR_LEVEL, default=SirenLevel.High_level_sound
): cv.positive_int,
vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int,
vol.Optional(
ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00
): cv.positive_int,
vol.Optional(
ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH
ATTR_WARNING_DEVICE_STROBE_INTENSITY,
default=StrobeLevel.High_level_strobe,
): cv.positive_int,
}
),
@@ -424,10 +427,7 @@ async def websocket_get_groupable_devices(
),
}
for entity_ref in entity_refs
if list(entity_ref.entity_data.entity.cluster_handlers.values())[
0
].cluster.endpoint.endpoint_id
== ep_id
if entity_ref.entity_data.entity.endpoint.id == ep_id
],
"device": device.zha_device_info,
}
@@ -649,7 +649,7 @@ async def websocket_reconfigure_node(
connection.send_message(websocket_api.event_message(msg["id"], data))
remove_dispatcher_function = async_dispatcher_connect(
hass, ZHA_CLUSTER_HANDLER_MSG, forward_messages
hass, SIGNAL_DEVICE_RECONFIGURE_EVENT, forward_messages
)
@callback
@@ -1480,15 +1480,6 @@ def async_load_api(hass: HomeAssistant) -> None:
schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND],
)
def _get_ias_wd_cluster_handler(zha_device):
"""Get the IASWD cluster handler for a device."""
cluster_handlers = {
ch.name: ch
for endpoint in zha_device.endpoints.values()
for ch in endpoint.claimed_cluster_handlers.values()
}
return cluster_handlers.get(CLUSTER_HANDLER_IAS_WD)
async def warning_device_squawk(service: ServiceCall) -> None:
"""Issue the squawk command for an IAS warning device."""
ieee: EUI64 = service.data[ATTR_IEEE]
@@ -1496,31 +1487,10 @@ def async_load_api(hass: HomeAssistant) -> None:
strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE]
level: int = service.data[ATTR_LEVEL]
if (zha_device := zha_gateway.get_device(ieee)) is not None:
if cluster_handler := _get_ias_wd_cluster_handler(zha_device):
await cluster_handler.issue_squawk(mode, strobe, level)
else:
_LOGGER.error(
"Squawking IASWD: %s: [%s] is missing"
" the required IASWD cluster handler!",
ATTR_IEEE,
str(ieee),
)
else:
_LOGGER.error(
"Squawking IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee)
)
_LOGGER.debug(
"Squawking IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]",
ATTR_IEEE,
str(ieee),
ATTR_WARNING_DEVICE_MODE,
mode,
ATTR_WARNING_DEVICE_STROBE,
strobe,
ATTR_LEVEL,
level,
)
device = zha_gateway.get_device(ieee)
siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True)
await siren.async_squawk(mode=mode, strobe=strobe, squawk_level=level)
async_register_admin_service(
hass,
@@ -1540,32 +1510,16 @@ def async_load_api(hass: HomeAssistant) -> None:
duty_mode: int = service.data[ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE]
intensity: int = service.data[ATTR_WARNING_DEVICE_STROBE_INTENSITY]
if (zha_device := zha_gateway.get_device(ieee)) is not None:
if cluster_handler := _get_ias_wd_cluster_handler(zha_device):
await cluster_handler.issue_start_warning(
mode, strobe, level, duration, duty_mode, intensity
)
else:
_LOGGER.error(
"Warning IASWD: %s: [%s] is missing"
" the required IASWD cluster handler!",
ATTR_IEEE,
str(ieee),
)
else:
_LOGGER.error(
"Warning IASWD: %s: [%s] could not be found!", ATTR_IEEE, str(ieee)
)
_LOGGER.debug(
"Warning IASWD: %s: [%s] %s: [%s] %s: [%s] %s: [%s]",
ATTR_IEEE,
str(ieee),
ATTR_WARNING_DEVICE_MODE,
mode,
ATTR_WARNING_DEVICE_STROBE,
strobe,
ATTR_LEVEL,
level,
device = zha_gateway.get_device(ieee)
siren: BaseSiren = device.get_entity(Platform.SIREN, pick_first=True)
await siren.async_turn_on(
tone=mode,
volume_level=level,
duration=duration,
strobe=strobe,
strobe_duty_cycle=duty_mode,
strobe_intensity=intensity,
)
async_register_admin_service(
+8
View File
@@ -3,5 +3,13 @@
"reload": {
"service": "mdi:reload"
}
},
"triggers": {
"entered": {
"trigger": "mdi:map-marker-plus"
},
"left": {
"trigger": "mdi:map-marker-minus"
}
}
}
@@ -1,8 +1,48 @@
{
"common": {
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
"trigger_zone_description": "The zone to trigger on.",
"trigger_zone_name": "Zone"
},
"services": {
"reload": {
"description": "Reloads zones from the YAML-configuration.",
"name": "Reload zones"
}
},
"triggers": {
"entered": {
"description": "Triggers when one or more persons or device trackers enter a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::trigger_zone_description%]",
"name": "[%key:component::zone::common::trigger_zone_name%]"
}
},
"name": "Entered zone"
},
"left": {
"description": "Triggers when one or more persons or device trackers leave a zone.",
"fields": {
"behavior": {
"name": "[%key:component::zone::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::zone::common::trigger_for_name%]"
},
"zone": {
"description": "[%key:component::zone::common::trigger_zone_description%]",
"name": "[%key:component::zone::common::trigger_zone_name%]"
}
},
"name": "Left zone"
}
}
}
+161 -76
View File
@@ -1,22 +1,24 @@
"""Offer zone automation rules."""
import logging
from typing import TYPE_CHECKING, Any, cast
import voluptuous as vol
from homeassistant.components.device_tracker import ATTR_IN_ZONES
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_PLATFORM,
CONF_OPTIONS,
CONF_ZONE,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HassJob,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
@@ -24,8 +26,18 @@ from homeassistant.helpers import (
entity_registry as er,
location,
)
from homeassistant.helpers.automation import (
DomainSpec,
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityTriggerBase,
Trigger,
TriggerActionRunner,
TriggerConfig,
)
from homeassistant.helpers.typing import ConfigType
from . import condition
@@ -38,93 +50,166 @@ _LOGGER = logging.getLogger(__name__)
_EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"}
_TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
_LEGACY_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
vol.Required(CONF_ZONE): cv.entity_id,
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(EVENT_ENTER, EVENT_LEAVE),
}
_LEGACY_TRIGGER_OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_PLATFORM): "zone",
vol.Required(CONF_ENTITY_ID): cv.entity_ids_or_uuids,
vol.Required(CONF_ZONE): cv.entity_id,
vol.Required(CONF_EVENT, default=DEFAULT_EVENT): vol.Any(
EVENT_ENTER, EVENT_LEAVE
),
vol.Required(CONF_OPTIONS): _LEGACY_OPTIONS_SCHEMA,
},
)
# New-style zone trigger schema
_ZONE_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_ZONE): cv.entity_domain("zone"),
},
}
)
async def async_validate_trigger_config(
hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate trigger config."""
config = _TRIGGER_SCHEMA(config)
registry = er.async_get(hass)
config[CONF_ENTITY_ID] = er.async_validate_entity_ids(
registry, config[CONF_ENTITY_ID]
)
return config
_DOMAIN_SPECS: dict[str, DomainSpec] = {
"person": DomainSpec(),
"device_tracker": DomainSpec(),
}
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
*,
platform_type: str = "zone",
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
trigger_data = trigger_info["trigger_data"]
entity_id: list[str] = config[CONF_ENTITY_ID]
zone_entity_id: str = config[CONF_ZONE]
event: str = config[CONF_EVENT]
job = HassJob(action)
class LegacyZoneTrigger(Trigger):
"""Legacy zone trigger (platform: zone)."""
@callback
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
"""Listen for state changes and calls action."""
entity = zone_event.data["entity_id"]
from_s = zone_event.data["old_state"]
to_s = zone_event.data["new_state"]
@classmethod
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
) -> ConfigType:
"""Validate complete config, migrating legacy format to options."""
complete_config = move_top_level_schema_fields_to_options(
complete_config, _LEGACY_OPTIONS_SCHEMA
)
return await super().async_validate_complete_config(hass, complete_config)
if (from_s and not location.has_location(from_s)) or (
to_s and not location.has_location(to_s)
):
return
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
config = cast(ConfigType, _LEGACY_TRIGGER_OPTIONS_SCHEMA(config))
registry = er.async_get(hass)
config[CONF_OPTIONS][CONF_ENTITY_ID] = er.async_validate_entity_ids(
registry, config[CONF_OPTIONS][CONF_ENTITY_ID]
)
return config
if not (zone_state := hass.states.get(zone_entity_id)):
_LOGGER.warning(
(
"Automation '%s' is referencing non-existing zone '%s' in a zone"
" trigger"
),
trigger_info["name"],
zone_entity_id,
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Listen for state changes based on configuration."""
entity_id: list[str] = self._options[CONF_ENTITY_ID]
zone_entity_id: str = self._options[CONF_ZONE]
event: str = self._options[CONF_EVENT]
@callback
def zone_automation_listener(zone_event: Event[EventStateChangedData]) -> None:
"""Listen for state changes and calls action."""
entity = zone_event.data["entity_id"]
from_s = zone_event.data["old_state"]
to_s = zone_event.data["new_state"]
if (from_s and not location.has_location(from_s)) or (
to_s and not location.has_location(to_s)
):
return
if not (zone_state := self._hass.states.get(zone_entity_id)):
_LOGGER.warning(
"Non-existing zone '%s' in a zone trigger",
zone_entity_id,
)
return
from_match = (
condition.zone(self._hass, zone_state, from_s) if from_s else False
)
return
to_match = condition.zone(self._hass, zone_state, to_s) if to_s else False
from_match = condition.zone(hass, zone_state, from_s) if from_s else False
to_match = condition.zone(hass, zone_state, to_s) if to_s else False
if (event == EVENT_ENTER and not from_match and to_match) or (
event == EVENT_LEAVE and from_match and not to_match
):
description = (
f"{entity} {_EVENT_DESCRIPTION[event]}"
f" {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
)
hass.async_run_hass_job(
job,
{
"trigger": {
**trigger_data,
"platform": platform_type,
if (event == EVENT_ENTER and not from_match and to_match) or (
event == EVENT_LEAVE and from_match and not to_match
):
description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
run_action(
{
"entity_id": entity,
"from_state": from_s,
"to_state": to_s,
"zone": zone_state,
"event": event,
"description": description,
}
},
to_s.context if to_s else None,
)
},
description,
to_s.context if to_s else None,
)
return async_track_state_change_event(hass, entity_id, zone_automation_listener)
return async_track_state_change_event(
self._hass, entity_id, zone_automation_listener
)
class ZoneTriggerBase(EntityTriggerBase):
"""Base for zone-based triggers targeting person and device_tracker entities."""
_domain_specs = _DOMAIN_SPECS
_schema = _ZONE_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the trigger."""
super().__init__(hass, config)
self._zone: str = self._options[CONF_ZONE]
def _in_target_zone(self, state: State) -> bool:
"""Check if the entity is in the selected zone."""
in_zones = state.attributes.get(ATTR_IN_ZONES) or ()
return self._zone in in_zones
class EnteredZoneTrigger(ZoneTriggerBase):
"""Trigger when an entity enters the selected zone."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the entity was not already in the selected zone."""
return not self._in_target_zone(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the entity is now in the selected zone."""
return self._in_target_zone(state)
class LeftZoneTrigger(ZoneTriggerBase):
"""Trigger when an entity leaves the selected zone."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the entity was previously in the selected zone."""
return self._in_target_zone(from_state)
def is_valid_state(self, state: State) -> bool:
"""Check that the entity is no longer in the selected zone."""
return not self._in_target_zone(state)
TRIGGERS: dict[str, type[Trigger]] = {
"_": LegacyZoneTrigger,
"entered": EnteredZoneTrigger,
"left": LeftZoneTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for zones."""
return TRIGGERS
@@ -0,0 +1,26 @@
.trigger_zone: &trigger_zone
target:
entity:
domain:
- person
- device_tracker
fields:
behavior:
required: true
default: each
selector:
automation_behavior:
mode: trigger
for:
required: true
default: 00:00:00
selector:
duration:
zone:
required: true
selector:
entity:
domain: zone
entered: *trigger_zone
left: *trigger_zone
+2 -2
View File
@@ -14,8 +14,8 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 6
PATCH_VERSION: Final = "0b0"
MINOR_VERSION: Final = 7
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
+6 -6
View File
@@ -3759,6 +3759,12 @@
"iot_class": "cloud_push",
"name": "LG ThinQ"
},
"lg_tv_rs232": {
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling",
"name": "LG TV via Serial"
},
"webostv": {
"integration_type": "device",
"config_flow": true,
@@ -3767,12 +3773,6 @@
}
}
},
"lg_tv_rs232": {
"name": "LG TV via Serial",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"libre_hardware_monitor": {
"name": "Libre Hardware Monitor",
"integration_type": "device",
+4 -4
View File
@@ -31,7 +31,6 @@ from homeassistant.const import (
MAX_LENGTH_STATE_DOMAIN,
MAX_LENGTH_STATE_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
EntityCategory,
Platform,
)
@@ -1951,9 +1950,10 @@ class EntityRegistry(BaseRegistry):
This should only be used when an entity needs to be migrated between
integrations.
"""
if (
state := self.hass.states.get(entity_id)
) is not None and state.state != STATE_UNKNOWN:
# import here to avoid circular import
from .entity import entity_sources # noqa: PLC0415
if entity_id in entity_sources(self.hass):
raise ValueError("Only entities that haven't been loaded can be migrated")
old = self.entities[entity_id]
+8 -1
View File
@@ -1714,7 +1714,14 @@ def async_extract_entities(trigger_conf: dict) -> list[str]:
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
if trigger_conf[CONF_PLATFORM] == "zone":
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]
options = trigger_conf[CONF_OPTIONS]
return [*options[CONF_ENTITY_ID], options[CONF_ZONE]]
if trigger_conf[CONF_PLATFORM] in ("zone.entered", "zone.left"):
return [
*async_extract_targets(trigger_conf, CONF_ENTITY_ID),
trigger_conf[CONF_OPTIONS][CONF_ZONE],
]
if trigger_conf[CONF_PLATFORM] == "geo_location":
return [trigger_conf[CONF_ZONE]]
+1 -1
View File
@@ -30,7 +30,7 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==48.0.0
dbus-fast==5.0.15
dbus-fast==5.0.16
file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
go2rtc-client==0.4.0
+23
View File
@@ -99,6 +99,8 @@ Every check has a code following the
| `W7407` | [`home-assistant-config-flow-polling-field`](#w7407-home-assistant-config-flow-polling-field) | Config flow should not include polling interval fields |
| `W7408` | [`home-assistant-config-flow-name-field`](#w7408-home-assistant-config-flow-name-field) | Config flow should not include name fields |
| `R7402` | [`home-assistant-unused-test-fixture-argument`](#r7402-home-assistant-unused-test-fixture-argument) | Unused test function argument should use `@pytest.mark.usefixtures` |
| `W7418` | [`home-assistant-tests-direct-async-setup-entry`](#w7418-home-assistant-tests-direct-async-setup-entry) | Tests should not call an integration's `async_setup_entry` directly |
| `W7420` | [`home-assistant-tests-direct-platform-async-setup-entry`](#w7420-home-assistant-tests-direct-platform-async-setup-entry) | Tests should not call a platform's `async_setup_entry` directly |
| `W7422` | [`home-assistant-tests-direct-async-setup`](#w7422-home-assistant-tests-direct-async-setup) | Tests should not call an integration's `async_setup` directly |
@@ -342,6 +344,27 @@ only needed for its side effects.
This rule only applies to `test_*` functions, not to fixture functions.
## `home_assistant_tests_direct_async_setup_entry` checker
Detects tests that call an integration's `async_setup_entry` directly.
### `W7418`: `home-assistant-tests-direct-async-setup-entry`
Tests should not invoke an integration's `async_setup_entry` from
`__init__.py` directly. Instead, tests should let Home Assistant perform
the setup via `await hass.config_entries.async_setup(entry.entry_id)` so
that the real setup pipeline (platforms, services, listeners, unload
handlers, etc.) is exercised.
### `W7420`: `home-assistant-tests-direct-platform-async-setup-entry`
Same as `W7418`, but for an entity platform's `async_setup_entry` (e.g.
`homeassistant.components.<integration>.sensor.async_setup_entry`).
Tests should drive setup through `hass.config_entries.async_setup` so
the platform is loaded via the normal Home Assistant flow.
See [epic #77](https://github.com/home-assistant/epics/issues/77).
## `home_assistant_tests_direct_async_setup` checker
@@ -0,0 +1,138 @@
"""Checker for direct calls to ``async_setup_entry`` from tests.
Tests should not invoke an integration's ``async_setup_entry`` directly
(either the one in ``__init__.py`` or in an entity-platform module).
Instead, tests should let Home Assistant perform the setup via
``await hass.config_entries.async_setup(entry.entry_id)`` so that the
real setup pipeline (platforms, services, listeners, unload handlers,
etc.) is exercised.
This checker flags any call to ``async_setup_entry`` (whether awaited or
not, accessed as a name or an attribute) made from a test module whose
target resolves to a module-level function defined under
``homeassistant.components.*``. The integration-init case and the
entity-platform case get separate messages so violations can be tracked
and fixed independently.
"""
from enum import Enum
import astroid
from astroid import nodes
from pylint.checkers import BaseChecker
from pylint.lint import PyLinter
from pylint_home_assistant.helpers.module_info import is_test_module, parse_module
class _SetupKind(Enum):
"""The kind of integration ``async_setup_entry`` being called."""
INIT = "init"
PLATFORM = "platform"
def _resolve_integration_async_setup_entry(call: nodes.Call) -> _SetupKind | None:
"""Return the kind of integration ``async_setup_entry`` *call* targets.
Returns ``_SetupKind.INIT`` if the target is in the integration's
``__init__`` module, ``_SetupKind.PLATFORM`` if it is in an
entity-platform module, or ``None`` if the call does not resolve to
an integration's ``async_setup_entry``.
"""
func = call.func
match func:
case nodes.Attribute(attrname="async_setup_entry"):
pass
case nodes.Name(name="async_setup_entry"):
pass
case _:
return None
seen_qnames: set[str] = set()
try:
for inferred in func.infer():
if inferred is astroid.Uninferable:
continue
if not isinstance(inferred, (nodes.FunctionDef, nodes.AsyncFunctionDef)):
continue
# Require the function to be defined at module level so that
# class methods named ``async_setup_entry`` (whose qname
# includes the class name) are not classified as integration
# setup functions.
if not isinstance(inferred.parent, nodes.Module):
continue
module_qname = inferred.parent.qname()
if not module_qname or module_qname in seen_qnames:
continue
seen_qnames.add(module_qname)
parsed = parse_module(module_qname)
if parsed is None:
continue
return _SetupKind.INIT if parsed.module is None else _SetupKind.PLATFORM
except astroid.exceptions.InferenceError, astroid.exceptions.AstroidError:
return None
return None
class DirectAsyncSetupEntry(BaseChecker):
"""Checker for direct calls to async_setup_entry in tests."""
name = "home_assistant_tests_direct_async_setup_entry"
priority = -1
msgs = {
"W7418": (
(
"Do not call `async_setup_entry` directly from tests; use "
"`await hass.config_entries.async_setup(entry.entry_id)` instead"
),
"home-assistant-tests-direct-async-setup-entry",
(
"Used when a test module calls an integration's "
"`async_setup_entry` from `__init__.py` directly. Tests should "
"let Home Assistant drive the setup so the full setup pipeline "
"is exercised."
),
),
"W7420": (
(
"Do not call a platform's `async_setup_entry` directly from "
"tests; use `await hass.config_entries.async_setup(entry.entry_id)`"
" instead"
),
"home-assistant-tests-direct-platform-async-setup-entry",
(
"Used when a test module calls an integration entity platform's "
"`async_setup_entry` directly. Tests should let Home Assistant "
"drive the setup so the full setup pipeline is exercised."
),
),
}
options = ()
_in_test_module: bool = False
def visit_module(self, node: nodes.Module) -> None:
"""Record whether the current module is a test module."""
self._in_test_module = is_test_module(node.name)
def visit_call(self, node: nodes.Call) -> None:
"""Flag direct calls to an integration's async_setup_entry."""
if not self._in_test_module:
return
match _resolve_integration_async_setup_entry(node):
case _SetupKind.INIT:
self.add_message(
"home-assistant-tests-direct-async-setup-entry",
node=node,
)
case _SetupKind.PLATFORM:
self.add_message(
"home-assistant-tests-direct-platform-async-setup-entry",
node=node,
)
def register(linter: PyLinter) -> None:
"""Register the checker."""
linter.register_checker(DirectAsyncSetupEntry(linter))
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.6.0b0"
version = "2026.7.0.dev0"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+8 -8
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.8.0
aioamazondevices==13.8.1
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -797,7 +797,7 @@ datadog==0.52.0
datapoint==0.12.1
# homeassistant.components.bluetooth
dbus-fast==5.0.15
dbus-fast==5.0.16
# homeassistant.components.debugpy
debugpy==1.8.17
@@ -1371,7 +1371,7 @@ insteon-frontend-home-assistant==0.6.2
intellifire4py==4.4.0
# homeassistant.components.iometer
iometer==0.4.0
iometer==1.0.1
# homeassistant.components.iotty
iottycloud==0.3.0
@@ -1737,7 +1737,7 @@ ondilo==0.5.0
onedrive-personal-sdk==0.1.7
# homeassistant.components.onvif
onvif-zeep-async==4.1.0
onvif-zeep-async==4.1.1
# homeassistant.components.onvif
onvif_parsers==2.3.0
@@ -2929,7 +2929,7 @@ rxv==0.7.0
samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
samsungtvws[async,encrypted]==2.7.2
samsungtvws[async,encrypted]==3.0.5
# homeassistant.components.sanix
sanix==1.0.6
@@ -3357,7 +3357,7 @@ whois==0.9.27
wiffi==1.1.2
# homeassistant.components.wiim
wiim==0.1.2
wiim==0.1.4
# homeassistant.components.wirelesstag
wirelesstagpy==0.8.1
@@ -3403,7 +3403,7 @@ yalexs-ble==3.3.0
# homeassistant.components.august
# homeassistant.components.yale
yalexs==9.2.0
yalexs==9.2.1
# homeassistant.components.yeelight
yeelight==0.7.16
@@ -3442,7 +3442,7 @@ zeroconf==0.149.16
zeversolar==0.3.2
# homeassistant.components.zha
zha==1.3.1
zha==1.4.0
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
+116 -1
View File
@@ -6,15 +6,18 @@ from unittest.mock import patch
import pytest
from homeassistant.components.analytics import LABS_SNAPSHOT_FEATURE
from homeassistant.components.analytics import CONF_SNAPSHOTS_URL, LABS_SNAPSHOT_FEATURE
from homeassistant.components.analytics.const import (
BASIC_ENDPOINT_URL,
BASIC_ENDPOINT_URL_DEV,
DOMAIN,
SNAPSHOT_DEFAULT_URL,
SNAPSHOT_URL_PATH,
STORAGE_KEY,
)
from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.components.labs import async_update_preview_feature
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -37,6 +40,118 @@ async def test_setup(hass: HomeAssistant) -> None:
assert DOMAIN in hass.data
async def test_setup_with_snapshots_url(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test setup with snapshots_url in YAML config sends snapshots to that URL."""
custom_url = "https://custom-snapshot-endpoint.example.com"
snapshot_endpoint = custom_url + SNAPSHOT_URL_PATH
aioclient_mock.post(snapshot_endpoint, status=200, json={})
with patch(
"homeassistant.components.analytics.analytics._async_snapshot_payload",
return_value={"mock": {}},
):
assert await async_setup_component(hass, "labs", {})
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {CONF_SNAPSHOTS_URL: custom_url}}
)
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id(
{"type": "analytics/preferences", "preferences": {"snapshots": True}}
)
assert (await ws_client.receive_json())["success"]
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
await hass.async_block_till_done()
assert any(str(call[1]) == snapshot_endpoint for call in aioclient_mock.mock_calls)
async def test_setup_entry_supervisor_not_ready(hass: HomeAssistant) -> None:
"""Test that HassioNotReadyError raises ConfigEntryNotReady."""
with (
patch(
"homeassistant.components.analytics.analytics.is_hassio",
return_value=True,
),
patch(
"homeassistant.components.hassio.get_supervisor_info",
side_effect=HassioNotReadyError,
),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_schedule_starts_and_sends_analytics(
hass: HomeAssistant,
hass_storage: dict[str, Any],
hass_ws_client: WebSocketGenerator,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that the analytics schedule fires and sends analytics after time travel."""
aioclient_mock.post(BASIC_ENDPOINT_URL, status=200)
aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200)
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION):
await ws_client.send_json_auto_id(
{"type": "analytics/preferences", "preferences": {"base": True}}
)
assert (await ws_client.receive_json())["success"]
assert len(aioclient_mock.mock_calls) == 0
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=901))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
@pytest.mark.parametrize(
("ws_type", "ws_options"),
[("analytics", {}), ("analytics/preferences", {"preferences": {"base": True}})],
)
async def test_websocket_not_loaded(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
ws_type: str,
ws_options: dict[str, Any],
) -> None:
"""Test websocket returns error when analytics entry failed to load."""
with (
patch(
"homeassistant.components.analytics.analytics.is_hassio",
return_value=True,
),
patch(
"homeassistant.components.hassio.get_supervisor_info",
side_effect=HassioNotReadyError,
),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
ws_client = await hass_ws_client(hass)
await ws_client.send_json_auto_id({"type": ws_type} | ws_options)
response = await ws_client.receive_json()
assert not response["success"]
assert response["error"]["code"] == "not_found"
@pytest.mark.usefixtures("mock_snapshot_payload")
async def test_labs_feature_toggle(
hass: HomeAssistant,
+30
View File
@@ -2088,6 +2088,36 @@ async def test_receive_backup_path_traversal(
assert resp.status == 400
@pytest.mark.parametrize(
"name",
[
"/absolute/path",
"../parent",
"with/slash",
],
)
async def test_receive_backup_rejects_unsafe_inner_name(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
name: str,
) -> None:
"""Test receive backup rejects an inner name that would escape the backup dir."""
await setup_backup_integration(hass)
client = await hass_client()
backup = replace(TEST_BACKUP_ABC123, name=name)
with patch(
"homeassistant.components.backup.manager.read_backup",
return_value=backup,
):
resp = await client.post(
"/api/backup/upload?agent_id=backup.local",
data={"file": StringIO("test")},
)
assert resp.status == 400
async def test_receive_backup_busy_manager(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
+32
View File
@@ -14,6 +14,7 @@ import pytest
import securetar
from homeassistant.components.backup import DOMAIN, AddonInfo, AgentBackup, Folder
from homeassistant.components.backup.models import InvalidBackupFilename
from homeassistant.components.backup.util import (
DecryptedBackupStreamer,
EncryptedBackupStreamer,
@@ -158,6 +159,37 @@ def test_read_backup(backup_json_content: bytes, expected_backup: AgentBackup) -
assert backup == expected_backup
@pytest.mark.parametrize(
"name",
[
"/absolute/path",
"../parent",
"with/slash",
"with\\backslash",
"C:\\drive\\path",
"",
".",
"..",
],
)
def test_read_backup_rejects_unsafe_name(name: str) -> None:
"""Test that read_backup rejects names that could escape the backup directory."""
backup_json_content = (
b'{"compressed":true,"date":"2024-12-02T07:23:58.261875-05:00","homeassistant":'
b'{"exclude_database":true,"version":"2024.12.0.dev0"},"name":"'
+ name.encode().replace(b"\\", b"\\\\")
+ b'","protected":true,"slug":"455645fe","type":"partial","version":2}'
)
mock_path = Mock()
mock_path.stat.return_value.st_size = 1234
with patch("homeassistant.components.backup.util.tarfile.open") as mock_open_tar:
tar_ctx = mock_open_tar.return_value.__enter__.return_value
tar_ctx.extractfile.return_value.read.return_value = backup_json_content
with pytest.raises(InvalidBackupFilename):
read_backup(mock_path)
@pytest.mark.parametrize(
("backup", "password", "validation_result", "expected_messages"),
[

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