Compare commits

...

75 Commits

Author SHA1 Message Date
Erik 03e05083a8 Address review comments 2026-05-19 09:58:11 +02:00
Erik e76e923f26 Add new device tracker base entity BaseScannerEntity 2026-05-18 10:19:22 +02:00
James Nimmo 8854ad5765 Bump pyIntesishome to 1.8.8 (#171041) 2026-05-18 09:51:42 +02:00
Franck Nijhof 0eecb03b84 Add stop command to Overkiz pergola horizontal awning covers (#171034) 2026-05-18 08:57:44 +02:00
Mick Vleeshouwer 0c22c13b1f Add additional overrides to cover entity in Overkiz (#171019) 2026-05-18 08:49:37 +02:00
renovate[bot] bf56fad3f9 Update uv to 0.11.13 (#171048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 08:15:34 +02:00
Paulus Schoutsen 078d40ac54 Bump serialx to 1.8.0 (#171043)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 08:08:01 +02:00
Daniel Hjelseth Høyer 1b7bda06d3 Bump pyTibber to 0.37.6 (#170393)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-18 07:54:45 +02:00
renovate[bot] 828dde26e5 Update coverage to 7.14.0 (#171042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 22:38:10 -04:00
Nick Haghiri e8d21e57b3 Group sequential executor jobs in Backblaze B2 backup agent (#171045) 2026-05-17 22:37:41 -04:00
Noah Husby 481965eb0d Bump aiostreammagic to 2.13.1 (#171035) 2026-05-17 20:59:48 -04:00
Alex Taylor 2fcfa8320f Pin decorator to avoid license metadata regression (#171038) 2026-05-17 20:17:31 -04:00
Christian Lackas 95c68da115 Bump homematicip to 2.12.0 (#170968) 2026-05-17 17:47:50 -04:00
Ronald van der Meer d547076033 Bump python-duco-connectivity to 0.5.0 (#170989) 2026-05-17 17:46:43 -04:00
Franck Nijhof db0006c100 Fix shorthand template conditions in choose blocks crashing all automations (#171018) 2026-05-17 23:25:37 +02:00
Paulus Schoutsen f8d4826bf3 Send Marantz IR power-on command with repeat_count=5 (#171032)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 17:24:07 -04:00
Franck Nijhof 88f6b7159a Fix line length violations in tests/components j-l (#170961)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 17:16:11 -04:00
Franck Nijhof f7faed7330 Use timezone-aware date in SolarEdge energy details coordinator (#170969)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:42 -04:00
Franck Nijhof 302148b078 Fix line length violations in tests/components p-r (#170970) 2026-05-17 17:14:47 -04:00
Franck Nijhof 5b2816e56c Fix line length violations in tests/components s (#170990)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2026-05-17 17:14:26 -04:00
Franck Nijhof f7cf279648 Fix time trigger crash when using entity_id dict format without offset (#171006)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-05-17 17:13:32 -04:00
Franck Nijhof ee83a14391 Prevent Google Assistant entity sync from blocking startup (#170991)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 17:13:15 -04:00
Franck Nijhof 833ff982d0 Fix line length violations in tests/components t-z (#170994) 2026-05-17 17:12:29 -04:00
Paulus Schoutsen d8cb3ab4b8 Mount MariaDB/MySQL data directory on tmpfs in CI (#170915) 2026-05-17 17:06:28 -04:00
Paulus Schoutsen 23b0f550b1 Fix flaky homekit test_reload port check timeout (#171029)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 17:06:17 -04:00
Franck Nijhof c66eeed8f8 Use timezone-aware date in Ridwell pickup event filtering (#171001)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 17:05:54 -04:00
Franck Nijhof bdc9d881ea Load template extensions by class to prevent import deadlock (#170995)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 16:55:54 -04:00
Franck Nijhof 95e2f5e219 Use asyncio.get_running_loop() in emulated_hue UPnP responder (#171000)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 16:53:11 -04:00
Franck Nijhof 68fc5c0e87 Include entity ID and URL in REST switch error logs (#171008) 2026-05-17 16:49:19 -04:00
Franck Nijhof 67c1930c6f Fix threshold preview crash when hysteresis is not provided (#171009) 2026-05-17 16:48:36 -04:00
Franck Nijhof c90017d207 Fix Growatt mix device IndexError when chart data is empty (#171012) 2026-05-17 16:47:59 -04:00
Franck Nijhof 9dce6943de Fix SleepIQ timer units: seconds should be minutes for core climate and foot warmer (#171013) 2026-05-17 16:45:55 -04:00
Franck Nijhof 6a5faf2ec7 Fix Control4 climate crash when humidity is 'Undefined' (#171015) 2026-05-17 16:45:09 -04:00
Franck Nijhof d0711624c0 Fix manual alarm panel crash on restore with invalid state (#171016) 2026-05-17 16:44:39 -04:00
Franck Nijhof 03ea95dfd4 Handle Daikin connection errors gracefully in coordinator (#171017) 2026-05-17 16:44:02 -04:00
Franck Nijhof 721c736c03 Allow stop action with error: false and response_variable (#171020) 2026-05-17 16:42:27 -04:00
Franck Nijhof 1c105a5766 Fix Verisure alarm crash when cloud rejects arm/disarm command (#171024) 2026-05-17 16:41:26 -04:00
Franck Nijhof ad0324631b Fix Netatmo valve KeyError when hvac_action state is unavailable in Overkiz (#171004) 2026-05-17 20:55:24 +02:00
Franck Nijhof 83fbea2158 Fix line length violations in tests/components n-o (#170967) 2026-05-17 10:32:44 -04:00
Franck Nijhof 74c918b6b6 Use correct state_class for utility meters with device classes that don't support total_increasing (#170962)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 10:32:21 -04:00
Franck Nijhof ff7964bcfc Fix utility meter next_reset shifting forward on entity rename (#170957)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 10:31:56 -04:00
Franck Nijhof 9a1fd913bf Fix line length violations in tests (non-components) (#170804)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: frenck <195327+frenck@users.noreply.github.com>
2026-05-17 10:30:45 -04:00
Franck Nijhof f0396aca8a Fix line length violations in script/ (#170759)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 10:30:02 -04:00
Franck Nijhof 018e3a4765 Fix line length violations in tests/components m (#170965) 2026-05-17 13:18:23 +02:00
Franck Nijhof 2af7f43ed7 Fix line length violations in tests/components c (#170845)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-17 13:10:54 +02:00
Franck Nijhof 95878222fd Fix line length violations in tests/components i (#170958) 2026-05-17 13:10:09 +02:00
Franck Nijhof 95f3bd7c09 Fix line length violations in tests/components h (#170955)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-17 13:09:56 +02:00
Martin Claesson c366beab2e Add Kiosker button platform (#170558) 2026-05-17 12:50:44 +02:00
Franck Nijhof 88277d5920 Reduce GoodWe connect retries to avoid blocking startup (#170964)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 12:45:09 +02:00
Paulus Schoutsen 5e0aefd539 Add rf-protocols to renovate allowlist (#170944)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 05:42:27 -04:00
Paulus Schoutsen ff313f1e7f Fix flaky recorder entity registry collision tests (#170941)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:50:38 +02:00
Øyvind Matheson Wergeland 70f9395d02 Wrap nobo_hub entity action errors with translation keys (#170719) 2026-05-17 07:26:52 +02:00
TomFilsell b96f904d15 cert_expiry: Fix error attribute returning string "None" for valid certificates (#170878)
Co-authored-by: FIls0010 <a1867444@adelaide.edu.au>
2026-05-16 20:55:04 -04:00
Markus Adrario 0d16fa1e65 bump pyHomee to 1.4.0 (#170934) 2026-05-16 20:53:12 -04:00
wollew 27816fcb0c Bump pyvlx to 0.2.34 (#170919) 2026-05-16 20:52:45 -04:00
Simone Chemelli 4f0faf43c6 Bump aioamazondevices to 13.7.0 (#170935)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-05-16 20:51:40 -04:00
Paulus Schoutsen c28f5d3eed Add Marantz IR Remote integration (#169626)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:07:54 -04:00
puddly 7b589d6ce8 Disable USB discovery for teleinfo (#170933) 2026-05-16 18:59:46 -04:00
Michael b5556e17b2 Add exception translations to FRITZ!SmartHome (#170445)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-16 23:03:12 +02:00
Michael 407d29396a Fix swallowed exceptions in adguard action handlers (#170918) 2026-05-16 22:38:40 +02:00
Manu 7eaa132189 Return response only if requested in mastodon.update_profile action (#170921) 2026-05-16 21:50:40 +02:00
Franck Nijhof 87b151a436 Fix line length violations in tests/components d-f (#170881) 2026-05-16 21:17:51 +02:00
Paulus Schoutsen 19cbb3e5c9 Fix flaky kraken sensor test by waiting for background tasks (#170916)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:14:06 +02:00
Maciej Bieniek 675bbd704c Add Shelly occupancy binary sensor (#170894) 2026-05-16 12:25:02 -04:00
Abílio Costa 30a51e643f Make infrared test messages strict again (#170903) 2026-05-16 12:24:27 -04:00
Franck Nijhof 6ae50fffe1 Populate uid and recurrence_id in CalDAV calendar events (#170910) 2026-05-16 12:22:54 -04:00
Abílio Costa e60704ccec Remove rf-protocols requirement from individual integrations (#170912) 2026-05-16 12:22:11 -04:00
Mick Vleeshouwer ad9a7c08ab Fix is_closed state for SlidingDiscreteGateWithPedestrianPosition covers in Overkiz (#170913) 2026-05-16 17:58:22 +02:00
Franck Nijhof 198cb331ed Fix flaky plex update test (#170911) 2026-05-16 17:50:48 +02:00
Daniil Karpenko fc8949d4a2 Add tilt controls for UpDownSheerScreen in Overkiz (#170563)
Co-authored-by: Mick Vleeshouwer <mick@imick.nl>
2026-05-16 17:27:12 +02:00
iluebbe ed74360485 Bump rf-protocols to 3.2.0 (#170909)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:18:42 +01:00
Michael 9109cb5bfb Fix swallowed exceptions in synology_dsm action handlers (#170879) 2026-05-16 17:10:53 +02:00
Franck Nijhof 5c29580969 Fix line length violations in tests/components g (#170882) 2026-05-16 17:06:36 +02:00
Simone Chemelli 3813843c8c Bump aioamazondevices to 13.6.0 (#170904) 2026-05-16 17:04:34 +02:00
Abílio Costa 5ac7f898dd Re-add clarification comment to lg infrared (#170902) 2026-05-16 16:25:03 +02:00
1268 changed files with 11728 additions and 4267 deletions
+2 -1
View File
@@ -128,7 +128,8 @@
"home-assistant-bluetooth",
"home-assistant-frontend",
"home-assistant-intents",
"infrared-protocols"
"infrared-protocols",
"rf-protocols"
],
"enabled": true,
"minimumReleaseAge": null,
+5 -1
View File
@@ -1088,6 +1088,7 @@ jobs:
options: >-
--health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi"
--health-interval=5s --health-timeout=2s --health-retries=3
--tmpfs /var/lib/mysql:size=2g,mode=0750
needs:
- info
- base
@@ -1245,7 +1246,10 @@ jobs:
- 5432:5432
env:
POSTGRES_PASSWORD: password
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
options: >-
--health-cmd="pg_isready -hlocalhost -Upostgres"
--health-interval=5s --health-timeout=2s --health-retries=3
--tmpfs /var/lib/postgresql/data:size=2g,mode=0700
needs:
- info
- base
+1
View File
@@ -358,6 +358,7 @@ homeassistant.components.lunatone.*
homeassistant.components.lutron.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.marantz_infrared.*
homeassistant.components.mastodon.*
homeassistant.components.matrix.*
homeassistant.components.matter.*
Generated
+2
View File
@@ -1045,6 +1045,8 @@ CLAUDE.md @home-assistant/core
/tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea
/tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/marantz_infrared/ @balloob
/tests/components/marantz_infrared/ @balloob
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
/tests/components/mastodon/ @fabaff @andrew-codechimp
/homeassistant/components/matrix/ @PaarthShah
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "marantz",
"name": "Marantz",
"integrations": ["marantz", "marantz_infrared"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.3"]
"requirements": ["serialx==1.8.0"]
}
@@ -79,6 +79,12 @@
"exceptions": {
"config_entry_not_loaded": {
"message": "Config entry not loaded."
},
"error_while_turn_off": {
"message": "An error occurred while turning off AdGuard Home switch."
},
"error_while_turn_on": {
"message": "An error occurred while turning on AdGuard Home switch."
}
},
"services": {
+12 -7
View File
@@ -9,10 +9,11 @@ from adguardhome import AdGuardHome, AdGuardHomeError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdGuardConfigEntry, AdGuardData
from .const import DOMAIN, LOGGER
from .const import DOMAIN
from .entity import AdGuardHomeEntity
SCAN_INTERVAL = timedelta(seconds=10)
@@ -116,19 +117,23 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity):
"""Turn off the switch."""
try:
await self.entity_description.turn_off_fn(self.adguard)()
# pylint: disable-next=home-assistant-action-swallowed-exception
except AdGuardHomeError:
LOGGER.error("An error occurred while turning off AdGuard Home switch")
except AdGuardHomeError as err:
self._attr_available = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_while_turn_off",
) from err
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
try:
await self.entity_description.turn_on_fn(self.adguard)()
# pylint: disable-next=home-assistant-action-swallowed-exception
except AdGuardHomeError:
LOGGER.error("An error occurred while turning on AdGuard Home switch")
except AdGuardHomeError as err:
self._attr_available = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_while_turn_on",
) from err
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.5.0"]
"requirements": ["aioamazondevices==13.7.0"]
}
@@ -175,12 +175,13 @@ class BackblazeBackupAgent(BackupAgent):
"Attempting to delete partially uploaded backup file %s",
filename,
)
def _delete_uploaded_file() -> None:
"""Look up and delete the partially uploaded backup file."""
self._bucket.get_file_info_by_name(filename).delete()
try:
uploaded_main_file_info = await self._hass.async_add_executor_job(
self._bucket.get_file_info_by_name, filename
)
# pylint: disable-next=home-assistant-sequential-executor-jobs
await self._hass.async_add_executor_job(uploaded_main_file_info.delete)
await self._hass.async_add_executor_job(_delete_uploaded_file)
except B2Error:
_LOGGER.warning(
"Failed to clean up partially uploaded backup file %s;"
@@ -386,9 +387,12 @@ class BackblazeBackupAgent(BackupAgent):
metadata_file.file_name,
)
await self._hass.async_add_executor_job(file.delete)
# pylint: disable-next=home-assistant-sequential-executor-jobs
await self._hass.async_add_executor_job(metadata_file.delete)
def _delete_backup_files() -> None:
"""Delete the backup file and its metadata file."""
file.delete()
metadata_file.delete()
await self._hass.async_add_executor_job(_delete_backup_files)
self._invalidate_caches(
backup_id,
@@ -79,6 +79,12 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
end=self.to_local(self.get_end_date(vevent)),
location=get_attr_value(vevent, "location"),
description=get_attr_value(vevent, "description"),
uid=get_attr_value(vevent, "uid"),
recurrence_id=(
str(v)
if (v := get_attr_value(vevent, "recurrence_id")) is not None
else None
),
)
)
@@ -175,6 +181,12 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]):
end=self.to_local(self.get_end_date(vevent)),
location=get_attr_value(vevent, "location"),
description=get_attr_value(vevent, "description"),
uid=get_attr_value(vevent, "uid"),
recurrence_id=(
str(v)
if (v := get_attr_value(vevent, "recurrence_id")) is not None
else None
),
)
@staticmethod
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"quality_scale": "platinum",
"requirements": ["aiostreammagic==2.13.0"],
"requirements": ["aiostreammagic==2.13.1"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}
@@ -17,5 +17,7 @@ class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]):
"""Return additional sensor state attributes."""
return {
"is_valid": self.coordinator.is_cert_valid,
"error": str(self.coordinator.cert_error),
"error": str(self.coordinator.cert_error)
if self.coordinator.cert_error
else None,
}
+4 -1
View File
@@ -272,7 +272,10 @@ class Control4Climate(Control4Entity, ClimateEntity):
if data is None:
return None
humidity = data.get(CONTROL4_HUMIDITY)
return int(humidity) if humidity is not None else None
try:
return int(humidity) if humidity is not None else None
except ValueError, TypeError:
return None
@property
def hvac_mode(self) -> HVACMode:
+10 -2
View File
@@ -4,10 +4,11 @@ from datetime import timedelta
import logging
from pydaikin.daikin_base import Appliance
from pydaikin.exceptions import DaikinException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, TIMEOUT_SEC
@@ -33,4 +34,11 @@ class DaikinCoordinator(DataUpdateCoordinator[None]):
self.device = device
async def _async_update_data(self) -> None:
await self.device.update_status()
try:
await self.device.update_status()
except DaikinException as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="error_communicating",
translation_placeholders={"error": str(err)},
) from err
@@ -59,6 +59,9 @@
}
},
"exceptions": {
"error_communicating": {
"message": "Error communicating with Daikin device: {error}"
},
"zone_hvac_mode_unsupported": {
"message": "Zone temperature can only be changed when the main climate mode is heat or cool."
},
@@ -5,6 +5,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from .config_entry import ( # noqa: F401
BaseScannerEntity,
ScannerEntity,
ScannerEntityDescription,
TrackerEntity,
@@ -166,7 +166,11 @@ def _async_register_mac(
class BaseTrackerEntity(Entity):
"""Represent a tracked device."""
"""Represent a tracked device.
Not intended to be directly inherited by integrations. Integrations should
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
"""
_attr_device_info: None = None
_attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -304,6 +308,28 @@ class TrackerEntity(
return attr
class BaseScannerEntity(BaseTrackerEntity):
"""Base class for a tracked device that can be connected or disconnected.
Unlike ScannerEntity, this entity does not make assumptions about MAC
addresses being used to identify the device.
"""
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self) -> bool | None:
"""Return true if the device is connected."""
raise NotImplementedError
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
@@ -316,7 +342,7 @@ CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
class ScannerEntity(
BaseTrackerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device that is on a scanned network."""
@@ -341,18 +367,6 @@ class ScannerEntity(
"""Return hostname of the device."""
return self._attr_hostname
@property
def state(self) -> str:
"""Return the state of the device."""
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
raise NotImplementedError
@property
def unique_id(self) -> str | None:
"""Return unique ID of the entity."""
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco_connectivity"],
"quality_scale": "platinum",
"requirements": ["python-duco-connectivity==0.4.0"],
"requirements": ["python-duco-connectivity==0.5.0"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
@@ -169,7 +169,7 @@ async def async_create_upnp_datagram_endpoint(
ssdp_socket.bind(("" if upnp_bind_multicast else host_ip_addr, BROADCAST_PORT))
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
transport_protocol = await loop.create_datagram_endpoint(
lambda: UPNPResponderProtocol(loop, ssdp_socket, advertise_ip, advertise_port),
@@ -67,9 +67,15 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
try:
await self.hass.async_add_executor_job(self.fritz.login)
except RequestConnectionError as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connect_error",
) from err
except LoginError as err:
raise ConfigEntryAuthFailed from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="login_failed",
) from err
self.has_templates = await self.hass.async_add_executor_job(
self.fritz.has_templates
@@ -188,7 +194,10 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
ex,
)
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
raise UpdateFailed from ex
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connect_error_reload",
) from ex
for device in new_data.devices.values():
# create device registry entry for new main devices
@@ -106,6 +106,15 @@
"change_settings_while_lock_enabled": {
"message": "Can't change settings while manual access for telephone, app, or user interface is disabled on the device"
},
"connect_error": {
"message": "A connection error occurred while setting up the integration. The setup will be retried."
},
"connect_error_reload": {
"message": "A connection error occurred while updating the data from the FRITZ!Box. The integration is going to be reloaded to ensure a proper re-login."
},
"login_failed": {
"message": "Login failed, please check your username and password and try again."
},
"manual_switching_disabled": {
"message": "Can't toggle switch while manual switching is disabled for the device."
}
+1 -1
View File
@@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
host=host,
port=port,
family=model_family,
retries=10,
retries=3,
)
except InverterError as err:
try:
@@ -71,8 +71,8 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
"""Detects the port of the Inverter."""
port = GOODWE_UDP_PORT
try:
inverter = await connect(host=host, port=port, retries=10)
inverter = await connect(host=host, port=port, retries=3)
except InverterError:
port = GOODWE_TCP_PORT
inverter = await connect(host=host, port=port, retries=10)
inverter = await connect(host=host, port=port, retries=3)
return inverter, port
@@ -112,7 +112,7 @@ class AbstractConfig(ABC):
"""Sync entities to Google."""
await self.async_sync_entities_all()
self._on_deinitialize.append(start.async_at_start(self.hass, sync_google))
self._on_deinitialize.append(start.async_at_started(self.hass, sync_google))
@callback
def async_deinitialize(self) -> None:
@@ -215,14 +215,15 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
mix_chart_entries = mix_detail["chartData"]
sorted_keys = sorted(mix_chart_entries)
# Create datetime from the latest entry
date_now = dt_util.now().date()
last_updated_time = dt_util.parse_time(str(sorted_keys[-1]))
mix_detail["lastdataupdate"] = datetime.datetime.combine(
date_now,
last_updated_time, # type: ignore[arg-type]
dt_util.get_default_time_zone(),
)
if sorted_keys:
# Create datetime from the latest entry
date_now = dt_util.now().date()
last_updated_time = dt_util.parse_time(str(sorted_keys[-1]))
mix_detail["lastdataupdate"] = datetime.datetime.combine(
date_now,
last_updated_time, # type: ignore[arg-type]
dt_util.get_default_time_zone(),
)
# Dashboard data for mix system
dashboard_data = self.api.dashboard_data(self.plant_id)
@@ -268,9 +268,9 @@ async def async_attach_trigger( # noqa: C901
# entity
update_entity_trigger(at_time, new_state=hass.states.get(at_time))
to_track.append(TrackEntity(at_time, update_entity_trigger_event))
elif isinstance(at_time, dict) and CONF_OFFSET in at_time:
# entity with offset
entity_id: str = at_time.get(CONF_ENTITY_ID, "")
elif isinstance(at_time, dict):
# entity with optional offset
entity_id: str = at_time[CONF_ENTITY_ID]
offset: timedelta = at_time.get(CONF_OFFSET, timedelta(0))
update_entity_trigger(
entity_id, new_state=hass.states.get(entity_id), offset=offset
+1 -1
View File
@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["homee"],
"quality_scale": "silver",
"requirements": ["pyHomee==1.3.8"],
"requirements": ["pyHomee==1.4.0"],
"zeroconf": [
{
"name": "homee-*",
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.11.0"]
"requirements": ["homematicip==2.12.0"]
}
@@ -7,6 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/honeywell_string_lights",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze",
"requirements": ["rf-protocols==3.0.0"]
"quality_scale": "bronze"
}
@@ -2,9 +2,7 @@
from ibeacon_ble import iBeaconAdvertisement
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.components.device_tracker import BaseScannerEntity, SourceType
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -46,10 +44,11 @@ async def async_setup_entry(
)
class IBeaconTrackerEntity(IBeaconEntity, BaseTrackerEntity):
class IBeaconTrackerEntity(IBeaconEntity, BaseScannerEntity):
"""An iBeacon Tracker entity."""
_attr_name = None
_attr_source_type: SourceType = SourceType.BLUETOOTH_LE
_attr_translation_key = "device_tracker"
def __init__(
@@ -67,14 +66,9 @@ class IBeaconTrackerEntity(IBeaconEntity, BaseTrackerEntity):
self._active = True
@property
def state(self) -> str:
"""Return the state of the device."""
return STATE_HOME if self._active else STATE_NOT_HOME
@property
def source_type(self) -> SourceType:
"""Return tracker source type."""
return SourceType.BLUETOOTH_LE
def is_connected(self) -> bool:
"""Return true if the device is connected."""
return self._active
@callback
def _async_seen(
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["pyintesishome"],
"quality_scale": "legacy",
"requirements": ["pyintesishome==1.8.7"]
"requirements": ["pyintesishome==1.8.8"]
}
+6 -1
View File
@@ -5,7 +5,12 @@ from homeassistant.core import HomeAssistant
from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool:
@@ -0,0 +1,99 @@
"""Button platform for Kiosker."""
from collections.abc import Callable
from dataclasses import dataclass
from kiosker import KioskerAPI
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KioskerConfigEntry
from .entity import KioskerEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class KioskerButtonEntityDescription(ButtonEntityDescription):
"""Describe a Kiosker button."""
action_fn: Callable[[KioskerAPI], None] | None = None
BUTTONS: tuple[KioskerButtonEntityDescription, ...] = (
KioskerButtonEntityDescription(
key="ping",
translation_key="ping",
entity_category=EntityCategory.DIAGNOSTIC,
action_fn=lambda api: api.ping(),
),
KioskerButtonEntityDescription(
key="navigateRefresh",
translation_key="navigate_refresh",
action_fn=lambda api: api.navigate_refresh(),
),
KioskerButtonEntityDescription(
key="navigateHome",
translation_key="navigate_home",
action_fn=lambda api: api.navigate_home(),
),
KioskerButtonEntityDescription(
key="navigateForward",
translation_key="navigate_forward",
action_fn=lambda api: api.navigate_forward(),
),
KioskerButtonEntityDescription(
key="navigateBackward",
translation_key="navigate_backward",
action_fn=lambda api: api.navigate_backward(),
),
KioskerButtonEntityDescription(
key="print",
translation_key="print",
action_fn=lambda api: api.print(),
),
KioskerButtonEntityDescription(
key="clearCache",
translation_key="clear_cache",
entity_category=EntityCategory.CONFIG,
action_fn=lambda api: api.clear_cache(),
),
KioskerButtonEntityDescription(
key="clearCookies",
translation_key="clear_cookies",
entity_category=EntityCategory.CONFIG,
action_fn=lambda api: api.clear_cookies(),
),
KioskerButtonEntityDescription(
key="screensaverInteract",
translation_key="screensaver_interact",
action_fn=lambda api: api.screensaver_interact(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: KioskerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Kiosker buttons based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
KioskerButton(coordinator, description) for description in BUTTONS
)
class KioskerButton(KioskerEntity, ButtonEntity):
"""Representation of a Kiosker button."""
entity_description: KioskerButtonEntityDescription
async def async_press(self) -> None:
"""Handle button press."""
if action_fn := self.entity_description.action_fn:
await self.hass.async_add_executor_job(action_fn, self.coordinator.api)
@@ -14,6 +14,35 @@
}
}
},
"button": {
"clear_cache": {
"default": "mdi:cached"
},
"clear_cookies": {
"default": "mdi:cookie-alert-outline"
},
"navigate_backward": {
"default": "mdi:arrow-left"
},
"navigate_forward": {
"default": "mdi:arrow-right"
},
"navigate_home": {
"default": "mdi:home-outline"
},
"navigate_refresh": {
"default": "mdi:refresh"
},
"ping": {
"default": "mdi:lan-pending"
},
"print": {
"default": "mdi:printer"
},
"screensaver_interact": {
"default": "mdi:sleep-off"
}
},
"sensor": {
"ambient_light": {
"default": "mdi:brightness-6"
@@ -56,6 +56,35 @@
"name": "Screensaver"
}
},
"button": {
"clear_cache": {
"name": "Clear cache"
},
"clear_cookies": {
"name": "Clear cookies"
},
"navigate_backward": {
"name": "Go back"
},
"navigate_forward": {
"name": "Go forward"
},
"navigate_home": {
"name": "Go home"
},
"navigate_refresh": {
"name": "Refresh page"
},
"ping": {
"name": "Ping"
},
"print": {
"name": "Print page"
},
"screensaver_interact": {
"name": "Dismiss screensaver"
}
},
"sensor": {
"ambient_light": {
"name": "Ambient light"
@@ -115,6 +115,8 @@ class LgIrReceivedCommandEvent(LgIrEntity, InfraredReceiverConsumerEntity, Event
try:
command_code = LGTVCode(nec_command.command)
except ValueError:
# Ensure that a future change to the LGTVCode enum doesn't break
# this and shows as unknown.
event_type = _EVENT_TYPE_UNKNOWN
else:
event_type = _COMMAND_CODE_TO_EVENT_TYPE.get(
@@ -455,12 +455,15 @@ class ManualAlarm(AlarmControlPanelEntity, RestoreEntity):
await super().async_added_to_hass()
if state := await self.async_get_last_state():
self._state_ts = state.last_updated
if next_state := state.attributes.get(ATTR_NEXT_STATE):
# If in arming or pending state we record the transition,
# not the current state
self._state = AlarmControlPanelState(next_state)
else:
self._state = AlarmControlPanelState(state.state)
try:
if next_state := state.attributes.get(ATTR_NEXT_STATE):
# If in arming or pending state we record the transition,
# not the current state
self._state = AlarmControlPanelState(next_state)
else:
self._state = AlarmControlPanelState(state.state)
except ValueError:
return
if prev_state := state.attributes.get(ATTR_PREVIOUS_STATE):
self._previous_state = prev_state
@@ -0,0 +1,37 @@
"""Marantz IR Remote integration for Home Assistant."""
from dataclasses import dataclass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.MEDIA_PLAYER]
@dataclass
class MarantzIrRuntimeData:
"""Runtime data for a Marantz IR config entry.
The RC-5 toggle bit must alternate between distinct key presses so
the receiver can distinguish a new press from a held-down repeat.
The toggle is tracked at the device level (one value per config
entry) so all entities of a config entry share it.
"""
toggle: int = 0
type MarantzIrConfigEntry = ConfigEntry[MarantzIrRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: MarantzIrConfigEntry) -> bool:
"""Set up Marantz IR from a config entry."""
entry.runtime_data = MarantzIrRuntimeData()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MarantzIrConfigEntry) -> bool:
"""Unload a Marantz IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,69 @@
"""Config flow for Marantz IR integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_INFRARED_EMITTER_ENTITY_ID, CONF_MODEL, DOMAIN, MODELS
class MarantzIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Marantz IR."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
emitter_entity_ids = async_get_emitters(self.hass)
if not emitter_entity_ids:
return self.async_abort(reason="no_emitters")
if user_input is not None:
entity_id = user_input[CONF_INFRARED_EMITTER_ENTITY_ID]
model = user_input[CONF_MODEL]
await self.async_set_unique_id(f"{model}_{entity_id}")
self._abort_if_unique_id_configured()
return self.async_create_entry(title=MODELS[model].name, data=user_input)
model_options = [
SelectOptionDict(value=slug, label=model.name)
for slug, model in MODELS.items()
]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_MODEL): SelectSelector(
SelectSelectorConfig(
options=model_options,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(CONF_INFRARED_EMITTER_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=emitter_entity_ids,
)
),
}
),
)
@@ -0,0 +1,13 @@
"""Constants for the Marantz IR integration."""
from infrared_protocols.codes.marantz import models as marantz_models
from homeassistant.util import slugify
DOMAIN = "marantz_infrared"
CONF_INFRARED_EMITTER_ENTITY_ID = "infrared_emitter_entity_id"
CONF_MODEL = "model"
MODELS: dict[str, marantz_models.MarantzModel] = {
slugify(model.name): model for model in marantz_models.ALL_MODELS
}
@@ -0,0 +1,47 @@
"""Common entity for Marantz IR integration."""
from infrared_protocols.codes.marantz import models as marantz_models
from infrared_protocols.codes.marantz.audio import MarantzAudioCode
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.helpers.device_registry import DeviceInfo
from . import MarantzIrConfigEntry
from .const import CONF_MODEL, DOMAIN, MODELS
class MarantzIrEntity(InfraredEmitterConsumerEntity):
"""Marantz IR base entity."""
_attr_has_entity_name = True
def __init__(
self,
entry: MarantzIrConfigEntry,
infrared_entity_id: str,
unique_id_suffix: str,
) -> None:
"""Initialize Marantz IR entity."""
self._infrared_emitter_entity_id = infrared_entity_id
self._runtime_data = entry.runtime_data
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
lib_model = MODELS[entry.data[CONF_MODEL]]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"Marantz {lib_model.name}",
manufacturer="Marantz",
model=None if lib_model is marantz_models.GENERIC else lib_model.name,
)
async def _send_marantz_command(
self, code: MarantzAudioCode, repeat_count: int = 0
) -> None:
"""Send an IR command using the Marantz protocol.
Flips the RC-5 toggle bit before each frame so the receiver
treats consecutive presses as new presses, not as a held repeat.
"""
self._runtime_data.toggle ^= 1
await self._send_command(
code.to_command(repeat_count=repeat_count, toggle=self._runtime_data.toggle)
)
@@ -0,0 +1,11 @@
{
"domain": "marantz_infrared",
"name": "Marantz Infrared",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/marantz_infrared",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "silver"
}
@@ -0,0 +1,150 @@
"""Media player platform for Marantz IR integration."""
from dataclasses import dataclass
from typing import Any
from infrared_protocols.codes.marantz.audio import MarantzAudioCode
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from . import MarantzIrConfigEntry
from .const import CONF_INFRARED_EMITTER_ENTITY_ID, CONF_MODEL, MODELS
from .entity import MarantzIrEntity
PARALLEL_UPDATES = 1
SOURCE_TO_CODE: dict[str, MarantzAudioCode] = {
"cd": MarantzAudioCode.SOURCE_CD,
"coax": MarantzAudioCode.SOURCE_COAX,
"laserdisc": MarantzAudioCode.SOURCE_LD,
"md": MarantzAudioCode.SOURCE_MD,
"network": MarantzAudioCode.SOURCE_NETWORK,
"optical": MarantzAudioCode.SOURCE_OPTICAL,
"phono": MarantzAudioCode.SOURCE_PHONO,
"recorder": MarantzAudioCode.SOURCE_CDR,
"satellite": MarantzAudioCode.SOURCE_SAT,
"tape": MarantzAudioCode.SOURCE_TAPE,
"tuner": MarantzAudioCode.SOURCE_TUNER,
"tv": MarantzAudioCode.SOURCE_TV,
"vcr": MarantzAudioCode.SOURCE_VCR1,
}
@dataclass
class _MarantzAmplifierExtraData(ExtraStoredData):
"""Persisted assumed-state data for a Marantz amplifier.
Stored separately from the entity state because while the amplifier is
OFF, ``MediaPlayerEntity.state_attributes`` strips ``source`` / mute,
so a restart in the OFF state would otherwise lose them.
"""
source: str | None
is_volume_muted: bool | None
def as_dict(self) -> dict[str, Any]:
"""Serialize for the restore-state store."""
return {"source": self.source, "is_volume_muted": self.is_volume_muted}
async def async_setup_entry(
hass: HomeAssistant,
entry: MarantzIrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Marantz IR media player from config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID]
async_add_entities([MarantzIrAmplifierMediaPlayer(entry, infrared_entity_id)])
class MarantzIrAmplifierMediaPlayer(MarantzIrEntity, MediaPlayerEntity, RestoreEntity):
"""Marantz IR amplifier media player entity."""
_attr_name = None
_attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_translation_key = "receiver"
def __init__(self, entry: MarantzIrConfigEntry, infrared_entity_id: str) -> None:
"""Initialize Marantz IR amplifier media player."""
super().__init__(entry, infrared_entity_id, unique_id_suffix="media_player")
codes = MODELS[entry.data[CONF_MODEL]].codes
self._source_to_code = {
source: code for source, code in SOURCE_TO_CODE.items() if code in codes
}
self._attr_source_list = list(self._source_to_code)
features = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
)
if self._source_to_code:
features |= MediaPlayerEntityFeature.SELECT_SOURCE
self._attr_supported_features = features
@property
def extra_restore_state_data(self) -> ExtraStoredData:
"""Persist source and mute regardless of ON/OFF state."""
return _MarantzAmplifierExtraData(
source=self._attr_source,
is_volume_muted=self._attr_is_volume_muted,
)
async def async_added_to_hass(self) -> None:
"""Restore last known assumed state, source, and mute."""
await super().async_added_to_hass()
if (last_state := await self.async_get_last_state()) is not None and (
last_state.state in (MediaPlayerState.ON, MediaPlayerState.OFF)
):
self._attr_state = MediaPlayerState(last_state.state)
if (extra := await self.async_get_last_extra_data()) is not None:
data = extra.as_dict()
if (source := data.get("source")) in self._source_to_code:
self._attr_source = source
if (muted := data.get("is_volume_muted")) is not None:
self._attr_is_volume_muted = bool(muted)
async def async_turn_on(self) -> None:
"""Send discrete power-on command."""
await self._send_marantz_command(MarantzAudioCode.POWER_ON, repeat_count=5)
self._attr_state = MediaPlayerState.ON
self.async_write_ha_state()
async def async_turn_off(self) -> None:
"""Send discrete power-off command."""
await self._send_marantz_command(MarantzAudioCode.POWER_OFF)
self._attr_state = MediaPlayerState.OFF
self.async_write_ha_state()
async def async_volume_up(self) -> None:
"""Send volume up command."""
await self._send_marantz_command(MarantzAudioCode.VOLUME_UP)
async def async_volume_down(self) -> None:
"""Send volume down command."""
await self._send_marantz_command(MarantzAudioCode.VOLUME_DOWN)
async def async_mute_volume(self, mute: bool) -> None:
"""Send discrete mute-on or mute-off command."""
await self._send_marantz_command(
MarantzAudioCode.MUTE_ON if mute else MarantzAudioCode.MUTE_OFF
)
self._attr_is_volume_muted = mute
self.async_write_ha_state()
async def async_select_source(self, source: str) -> None:
"""Select an input source."""
await self._send_marantz_command(self._source_to_code[source])
self._attr_source = source
self.async_write_ha_state()
@@ -0,0 +1,110 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup:
status: exempt
comment: |
This integration only proxies commands through an existing infrared
entity, so there is no separate connection to validate during setup.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
This integration is configured manually via config flow.
docs-data-update:
status: exempt
comment: |
This integration does not fetch data from devices.
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
No entities should be disabled by default.
entity-translations: done
exception-translations:
status: exempt
comment: |
This integration does not raise exceptions.
icon-translations:
status: exempt
comment: |
This integration does not use custom icons.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration has no external dependencies.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: done
@@ -0,0 +1,47 @@
{
"config": {
"abort": {
"already_configured": "This Marantz device has already been configured with this transmitter.",
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"step": {
"user": {
"data": {
"infrared_emitter_entity_id": "Infrared transmitter",
"model": "Model"
},
"data_description": {
"infrared_emitter_entity_id": "The infrared transmitter entity to use for sending commands.",
"model": "The Marantz model to control."
},
"description": "Select the Marantz model and the infrared transmitter entity to use for controlling your Marantz device.",
"title": "Set up Marantz IR Remote"
}
}
},
"entity": {
"media_player": {
"receiver": {
"state_attributes": {
"source": {
"state": {
"cd": "CD",
"coax": "Coax",
"laserdisc": "LaserDisc",
"md": "MD",
"network": "Network",
"optical": "Optical",
"phono": "Phono",
"recorder": "Recorder",
"satellite": "Satellite",
"tape": "Tape",
"tuner": "Tuner",
"tv": "TV",
"vcr": "VCR"
}
}
}
}
}
}
}
@@ -177,7 +177,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_UPDATE_PROFILE,
_async_update_profile,
schema=SERVICE_UPDATE_PROFILE_SCHEMA,
supports_response=SupportsResponse.ONLY,
supports_response=SupportsResponse.OPTIONAL,
)
@@ -382,7 +382,7 @@ def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None:
) from err
async def _async_update_profile(call: ServiceCall) -> ServiceResponse:
async def _async_update_profile(call: ServiceCall) -> ServiceResponse | None:
"""Update profile information."""
params = dict(call.data.copy())
@@ -406,7 +406,7 @@ async def _async_update_profile(call: ServiceCall) -> ServiceResponse:
if field[ATTR_NAME].strip()
]
try:
return await call.hass.async_add_executor_job(
response: Account = await call.hass.async_add_executor_job(
lambda: client.account_update_credentials(**params)
)
except MastodonUnauthorizedError as error:
@@ -421,6 +421,9 @@ async def _async_update_profile(call: ServiceCall) -> ServiceResponse:
translation_domain=DOMAIN,
translation_key="unable_to_update_profile",
) from err
if call.return_response:
return response
return None
async def _resolve_media(
+32 -14
View File
@@ -2,7 +2,7 @@
from typing import Any
from pynobo import nobo
from pynobo import PynoboError, nobo
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
@@ -22,6 +22,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
@@ -105,13 +106,18 @@ class NoboZone(NoboBaseEntity, ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target HVAC mode."""
if hvac_mode == HVACMode.AUTO:
await self.async_set_preset_mode(PRESET_NONE)
elif hvac_mode == HVACMode.HEAT:
await self.async_set_preset_mode(PRESET_COMFORT)
preset = PRESET_COMFORT if hvac_mode == HVACMode.HEAT else PRESET_NONE
await self._apply_preset(preset, "set_hvac_mode_failed")
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new zone override."""
await self._apply_preset(preset_mode, "set_preset_mode_failed")
async def _apply_preset(
self,
preset_mode: str,
translation_key: str,
) -> None:
if preset_mode == PRESET_ECO:
mode = nobo.API.OVERRIDE_MODE_ECO
elif preset_mode == PRESET_AWAY:
@@ -120,21 +126,33 @@ class NoboZone(NoboBaseEntity, ClimateEntity):
mode = nobo.API.OVERRIDE_MODE_COMFORT
else: # PRESET_NONE
mode = nobo.API.OVERRIDE_MODE_NORMAL
await self._nobo.async_create_override(
mode,
self._override_type,
nobo.API.OVERRIDE_TARGET_ZONE,
self._id,
)
try:
await self._nobo.async_create_override(
mode,
self._override_type,
nobo.API.OVERRIDE_TARGET_ZONE,
self._id,
)
except PynoboError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=translation_key,
) from err
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if ATTR_TARGET_TEMP_LOW in kwargs:
low = round(kwargs[ATTR_TARGET_TEMP_LOW])
high = round(kwargs[ATTR_TARGET_TEMP_HIGH])
await self._nobo.async_update_zone(
self._id, temp_comfort_c=high, temp_eco_c=low
)
try:
await self._nobo.async_update_zone(
self._id, temp_comfort_c=high, temp_eco_c=low
)
except PynoboError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature_failed",
) from err
async def async_update(self) -> None:
"""Fetch new state data for this zone."""
@@ -27,12 +27,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions:
status: todo
comment: >
Entity actions (climate set_hvac_mode/set_preset_mode/set_temperature,
select select_option) currently raise unwrapped exceptions; will wrap
in HomeAssistantError with translation keys.
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
@@ -73,7 +68,7 @@ rules:
PR #170135.
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
+11 -5
View File
@@ -1,6 +1,6 @@
"""Python Control of Nobø Hub - Nobø Energy Control."""
from pynobo import nobo
from pynobo import PynoboError, nobo
from homeassistant.components.select import SelectEntity
from homeassistant.const import ATTR_NAME
@@ -83,8 +83,11 @@ class NoboGlobalSelector(NoboBaseEntity, SelectEntity):
await self._nobo.async_create_override(
mode, self._override_type, nobo.API.OVERRIDE_TARGET_GLOBAL
)
except Exception as exp:
raise HomeAssistantError from exp
except PynoboError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_global_override_failed",
) from err
async def async_update(self) -> None:
"""Fetch new state data for this zone."""
@@ -126,8 +129,11 @@ class NoboProfileSelector(NoboBaseEntity, SelectEntity):
await self._nobo.async_update_zone(
self._id, week_profile_id=week_profile_id
)
except Exception as exp:
raise HomeAssistantError from exp
except PynoboError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_week_profile_failed",
) from err
async def async_update(self) -> None:
"""Fetch new state data for this zone."""
@@ -63,6 +63,21 @@
"exceptions": {
"cannot_connect": {
"message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, remove and re-add the integration."
},
"set_global_override_failed": {
"message": "Failed to set global override."
},
"set_hvac_mode_failed": {
"message": "Failed to set HVAC mode."
},
"set_preset_mode_failed": {
"message": "Failed to set preset mode."
},
"set_temperature_failed": {
"message": "Failed to set target temperature."
},
"set_week_profile_failed": {
"message": "Failed to set week profile."
}
},
"options": {
@@ -7,6 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/novy_cooker_hood",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "gold",
"requirements": ["rf-protocols==3.0.0"]
"quality_scale": "gold"
}
@@ -72,11 +72,13 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity):
)
@property
def hvac_action(self) -> HVACAction:
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac operation."""
return OVERKIZ_TO_HVAC_ACTION[
cast(str, self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE))
]
if (
state := self.executor.select_state(OverkizState.CORE_OPEN_CLOSED_VALVE)
) is None:
return None
return OVERKIZ_TO_HVAC_ACTION[cast(str, state)]
@property
def target_temperature(self) -> float:
+65
View File
@@ -70,6 +70,7 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
set_position_command=OverkizCommand.SET_DEPLOYMENT,
open_command=OverkizCommand.DEPLOY,
close_command=OverkizCommand.UNDEPLOY,
stop_command=OverkizCommand.STOP,
invert_position=False,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
),
@@ -80,6 +81,7 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
set_position_command=OverkizCommand.SET_DEPLOYMENT,
open_command=OverkizCommand.DEPLOY,
close_command=OverkizCommand.UNDEPLOY,
stop_command=OverkizCommand.STOP,
invert_position=False,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
),
@@ -148,6 +150,20 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
close_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands (rts:SheerBlindRTSComponent)
# uiClass is VenetianBlind
OverkizCoverDescription(
key=UIWidget.UP_DOWN_SHEER_SCREEN,
device_class=CoverDeviceClass.BLIND,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override since PositionableGarageDoor reports
# core:OpenClosedUnknownState instead of core:OpenClosedState
# uiClass is GarageDoor
@@ -185,6 +201,52 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
stop_command=OverkizCommand.STOP,
),
# Needs override since SlidingDiscreteGateWithPedestrianPosition reports
# core:OpenClosedPedestrianState instead of core:OpenClosedState
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.SLIDING_DISCRETE_GATE_WITH_PEDESTRIAN_POSITION,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
stop_command=OverkizCommand.STOP,
),
# Needs override since OpenCloseGateWithPedestrianPosition reports
# core:OpenClosedPedestrianState instead of core:OpenClosedState
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.OPEN_CLOSE_GATE_WITH_PEDESTRIAN_POSITION,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
stop_command=OverkizCommand.STOP,
),
# Needs override since OpenCloseSlidingGateWithPedestrianPosition reports
# core:OpenClosedPedestrianState instead of core:OpenClosedState
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.OPEN_CLOSE_SLIDING_GATE_WITH_PEDESTRIAN_POSITION,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
stop_command=OverkizCommand.STOP,
),
# Needs override since PositionableGateWithPedestrianPosition reports
# core:OpenClosedPedestrianState instead of core:OpenClosedState
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.POSITIONABLE_GATE_WITH_PEDESTRIAN_POSITION,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
stop_command=OverkizCommand.STOP,
),
# Needs override to support this Generic device (rts:GenericRTSComponent)
# uiClass is Generic (not mapped to cover as this is a Generic device class)
OverkizCoverDescription(
@@ -294,6 +356,9 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
stop_command=OverkizCommand.STOP,
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
stop_tilt_command=OverkizCommand.STOP,
),
OverkizCoverDescription(
key=UIClass.SCREEN,
@@ -4,10 +4,8 @@ from collections.abc import Mapping
import logging
from homeassistant.components import bluetooth
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity
from homeassistant.components.device_tracker import BaseScannerEntity, SourceType
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -25,11 +23,12 @@ async def async_setup_entry(
async_add_entities([BasePrivateDeviceTracker(config_entry)])
class BasePrivateDeviceTracker(BasePrivateDeviceEntity, BaseTrackerEntity):
class BasePrivateDeviceTracker(BasePrivateDeviceEntity, BaseScannerEntity):
"""A trackable Private Bluetooth Device."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_source_type: SourceType = SourceType.BLUETOOTH_LE
_attr_translation_key = "device_tracker"
_attr_name = None
@@ -60,11 +59,6 @@ class BasePrivateDeviceTracker(BasePrivateDeviceEntity, BaseTrackerEntity):
self.async_write_ha_state()
@property
def state(self) -> str:
"""Return the state of the device."""
return STATE_HOME if self._last_info else STATE_NOT_HOME
@property
def source_type(self) -> SourceType:
"""Return the source type, eg gps or router, of the device."""
return SourceType.BLUETOOTH_LE
def is_connected(self) -> bool:
"""Return true if the device is connected."""
return bool(self._last_info)
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["rf-protocols==3.0.0"]
"requirements": ["rf-protocols==3.2.0"]
}
+10 -2
View File
@@ -213,9 +213,17 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity):
try:
req = await self.get_response(self.hass)
except TimeoutError, httpx.TimeoutException:
_LOGGER.exception("Timed out while fetching data")
_LOGGER.exception(
"Timed out while fetching data for %s from %s",
self.entity_id,
self._state_resource,
)
except httpx.RequestError:
_LOGGER.exception("Error while fetching data")
_LOGGER.exception(
"Error fetching data for %s from %s",
self.entity_id,
self._state_resource,
)
if req:
self._async_update(req.text)
+2 -3
View File
@@ -1,11 +1,10 @@
"""Define a base Ridwell entity."""
from datetime import date
from aioridwell.model import RidwellAccount, RidwellPickupEvent
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import RidwellDataUpdateCoordinator
@@ -39,5 +38,5 @@ class RidwellEntity(CoordinatorEntity[RidwellDataUpdateCoordinator]):
return next(
event
for event in self.coordinator.data[self._account.account_id]
if event.pickup_date >= date.today() # noqa: DTZ011
if event.pickup_date >= dt_util.now().date()
)
@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
"requirements": ["serialx==1.7.3"]
"requirements": ["serialx==1.8.0"]
}
@@ -348,6 +348,11 @@ RPC_SENSORS: Final = {
device_class=BinarySensorDeviceClass.OCCUPANCY,
entity_class=RpcPresenceBinarySensor,
),
"occupancy": RpcBinarySensorDescription(
key="occupancy",
sub_key="value",
device_class=BinarySensorDeviceClass.OCCUPANCY,
),
"cury_tilt": RpcBinarySensorDescription(
key="cury",
sub_key="errors",
+3 -1
View File
@@ -158,6 +158,8 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = {
set_value_fn=_async_set_foot_warmer_time,
get_name_fn=_get_foot_warming_name,
get_unique_id_fn=_get_foot_warming_unique_id,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=NumberDeviceClass.DURATION,
),
CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription(
key=CORE_CLIMATE_TIMER,
@@ -170,7 +172,7 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = {
set_value_fn=_async_set_core_climate_time,
get_name_fn=_get_core_climate_name,
get_unique_id_fn=_get_core_climate_unique_id,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=NumberDeviceClass.DURATION,
),
}
@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from collections.abc import Iterable
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from aiosolaredge import SolarEdge
@@ -224,9 +224,8 @@ class SolarEdgeEnergyDetailsService(SolarEdgeDataService):
async def async_update_data(self) -> None:
"""Update the data from the SolarEdge Monitoring API."""
try:
now = datetime.now()
today = date.today() # noqa: DTZ011
midnight = datetime.combine(today, datetime.min.time())
now = dt_util.now()
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
data = await self.api.get_energy_details(
self.site_id,
midnight,
@@ -1,11 +1,12 @@
"""The Synology DSM component."""
import logging
from typing import TYPE_CHECKING, cast
from typing import cast
from synology_dsm.exceptions import SynologyDSMException
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES
from .coordinator import SynologyDSMConfigEntry
@@ -25,27 +26,37 @@ async def _service_handler(call: ServiceCall) -> None:
entry: SynologyDSMConfigEntry | None = (
call.hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial)
)
if TYPE_CHECKING:
assert entry
if not entry:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="serial_not_found",
translation_placeholders={"serial": serial},
)
dsm_device = entry.runtime_data
elif len(dsm_devices) == 1:
dsm_device = next(iter(dsm_devices.values()))
serial = next(iter(dsm_devices))
else:
LOGGER.error(
"More than one DSM configured, must specify one of serials %s",
sorted(dsm_devices),
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="missing_serial",
translation_placeholders={"serials": ", ".join(sorted(dsm_devices))},
)
return
if not dsm_device:
LOGGER.error("DSM with specified serial %s not found", serial)
return
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="serial_not_found",
translation_placeholders={"serial": serial},
)
if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]:
if serial not in dsm_devices:
LOGGER.error("DSM with specified serial %s not found", serial)
return
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="serial_not_found",
translation_placeholders={"serial": serial},
)
LOGGER.debug("%s DSM with serial %s", call.service, serial)
LOGGER.warning(
(
@@ -58,15 +69,16 @@ async def _service_handler(call: ServiceCall) -> None:
dsm_api = dsm_device.api
try:
await getattr(dsm_api, f"async_{call.service}")()
# pylint: disable-next=home-assistant-action-swallowed-exception
except SynologyDSMException as ex:
LOGGER.error(
"%s of DSM with serial %s not possible, because of %s",
call.service,
serial,
ex,
)
return
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="execution_error",
translation_placeholders={
"action": call.service,
"serial": serial,
"error": str(ex),
},
) from ex
@callback
@@ -187,6 +187,17 @@
}
}
},
"exceptions": {
"execution_error": {
"message": "Execute {action} on DSM with serial {serial} not possible, because of {error}."
},
"missing_serial": {
"message": "More than one DSM configured, must specify one of serials: {serials}."
},
"serial_not_found": {
"message": "DSM with specified serial {serial} not found."
}
},
"issues": {
"missing_backup_setup": {
"fix_flow": {
@@ -90,16 +90,6 @@ class TeleinfoConfigFlow(ConfigFlow, domain=DOMAIN):
usb.get_serial_by_id, discovery_info.device
)
# Validate by reading a real Teleinfo frame — silent abort on failure
errors, decoded_data = await self._validate_serial_port(dev_path)
if errors or decoded_data is None:
return self.async_abort(reason="not_teleinfo_device")
# Use ADCO (meter serial number) as unique_id — same as manual entry
adco = decoded_data["ADCO"]
await self.async_set_unique_id(adco)
self._abort_if_unique_id_configured(updates={CONF_SERIAL_PORT: dev_path})
self._discovered_device = dev_path
self.context["title_placeholders"] = {
"name": human_readable_device_name(
@@ -120,6 +110,20 @@ class TeleinfoConfigFlow(ConfigFlow, domain=DOMAIN):
if TYPE_CHECKING:
assert self._discovered_device is not None
if user_input is not None:
# Validate by reading a real Teleinfo frame — silent abort on failure
errors, decoded_data = await self._validate_serial_port(
self._discovered_device
)
if errors or decoded_data is None:
return self.async_abort(reason="not_teleinfo_device")
# Use ADCO (meter serial number) as unique_id — same as manual entry
adco = decoded_data["ADCO"]
await self.async_set_unique_id(adco)
self._abort_if_unique_id_configured(
updates={CONF_SERIAL_PORT: self._discovered_device}
)
return self.async_create_entry(
title=f"Teleinfo ({self._discovered_device})",
data={CONF_SERIAL_PORT: self._discovered_device},
@@ -8,15 +8,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["pyteleinfo==0.4.0"],
"usb": [
{
"pid": "6015",
"vid": "0403"
},
{
"pid": "EA60",
"vid": "10C4"
}
]
"requirements": ["pyteleinfo==0.4.0"]
}
@@ -137,7 +137,7 @@ def ws_start_preview(
name=name,
lower=msg["user_input"].get(CONF_LOWER),
upper=msg["user_input"].get(CONF_UPPER),
hysteresis=msg["user_input"].get(CONF_HYSTERESIS),
hysteresis=msg["user_input"].get(CONF_HYSTERESIS, DEFAULT_HYSTERESIS),
device_class=None,
unique_id=None,
)
+11 -4
View File
@@ -47,21 +47,28 @@ class TibberRuntimeData:
price_coordinator: TibberPriceCoordinator | None = field(default=None)
_client: tibber.Tibber | None = None
async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber:
"""Return an authenticated Tibber client."""
async def _async_get_access_token(self) -> str:
"""Return a valid Tibber access token."""
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
access_token: str | None = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
return access_token
async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber:
"""Return an authenticated Tibber client."""
access_token = await self._async_get_access_token()
if self._client is None:
self._client = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
refresh_access_token=self._async_get_access_token,
)
await self._client.set_access_token(access_token)
else:
await self._client.set_access_token(access_token)
return self._client
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.37.5"]
"requirements": ["pyTibber==0.37.6"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.3"]
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.8.0"]
}
@@ -13,6 +13,7 @@ import voluptuous as vol
from homeassistant.components.sensor import (
ATTR_LAST_RESET,
DEVICE_CLASS_STATE_CLASSES,
DEVICE_CLASS_UNITS,
RestoreSensor,
SensorDeviceClass,
@@ -406,11 +407,12 @@ class UtilityMeterSensor(RestoreSensor):
self._current_tz = None
self._config_scheduler()
def _config_scheduler(self):
def _config_scheduler(self, start_time: datetime | None = None) -> None:
self.scheduler = (
CronSim(
self._cron_pattern,
dt_util.now(
start_time
or dt_util.now(
dt_util.get_default_time_zone()
), # we need timezone for DST purposes (see issue #102984)
)
@@ -608,8 +610,6 @@ class UtilityMeterSensor(RestoreSensor):
# and we need to reconfigure the scheduler
self._current_tz = self.hass.config.time_zone
await self._program_reset()
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_RESET_METER, self.async_reset_meter
@@ -628,6 +628,13 @@ class UtilityMeterSensor(RestoreSensor):
if last_sensor_data.status == COLLECTING:
# Null lambda to allow cancelling the collection on tariff change
self._collecting = lambda: None
# Reconfigure the scheduler from the restored last_reset so that
# next_reset is not shifted forward on entity restore/rename.
self._config_scheduler(
dt_util.as_local(self._last_reset) if self._last_reset else None
)
await self._program_reset()
@callback
def async_source_tracking(event):
@@ -695,12 +702,18 @@ class UtilityMeterSensor(RestoreSensor):
@property
def state_class(self) -> SensorStateClass:
"""Return the device class of the sensor."""
return (
SensorStateClass.TOTAL
if self._sensor_net_consumption
else SensorStateClass.TOTAL_INCREASING
)
"""Return the state class of the sensor."""
if self._sensor_net_consumption:
return SensorStateClass.TOTAL
if (
self._input_device_class is not None
and SensorStateClass.TOTAL_INCREASING
not in DEVICE_CLASS_STATE_CLASSES.get(
self._input_device_class, {SensorStateClass.TOTAL_INCREASING}
)
):
return SensorStateClass.TOTAL
return SensorStateClass.TOTAL_INCREASING
@property
def extra_state_attributes(self) -> dict[str, Any]:
+1 -1
View File
@@ -14,5 +14,5 @@
"iot_class": "local_polling",
"loggers": ["pyvlx"],
"quality_scale": "silver",
"requirements": ["pyvlx==0.2.33"]
"requirements": ["pyvlx==0.2.34"]
}
@@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel import (
CodeFormat,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -63,6 +64,12 @@ class VerisureAlarm(
self.coordinator.verisure.request, command_data
)
LOGGER.debug("Verisure set arm state %s", state)
if arm_state is None or "data" not in arm_state:
await self.coordinator.async_refresh()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="arm_state_failed",
)
result = None
attempts = 0
while result is None:
@@ -77,6 +84,8 @@ class VerisureAlarm(
list(arm_state["data"].values())[0], state
),
)
if transaction is None:
continue
result = (
transaction.get("data", {})
.get("installation", {})
@@ -51,6 +51,11 @@
}
}
},
"exceptions": {
"arm_state_failed": {
"message": "Failed to change alarm state. Verify your code is correct and that your account is not temporarily locked."
}
},
"options": {
"step": {
"init": {
+1
View File
@@ -430,6 +430,7 @@ FLOWS = {
"lyric",
"madvr",
"mailgun",
"marantz_infrared",
"mastodon",
"matter",
"mcp",
+14 -2
View File
@@ -4032,8 +4032,20 @@
},
"marantz": {
"name": "Marantz",
"integration_type": "virtual",
"supported_by": "denonavr"
"integrations": {
"marantz": {
"integration_type": "virtual",
"config_flow": false,
"supported_by": "denonavr",
"name": "Marantz"
},
"marantz_infrared": {
"integration_type": "device",
"config_flow": true,
"iot_class": "assumed_state",
"name": "Marantz Infrared"
}
}
},
"martec": {
"name": "Martec",
-10
View File
@@ -58,16 +58,6 @@ USB = [
"pid": "0003",
"vid": "04B4",
},
{
"domain": "teleinfo",
"pid": "6015",
"vid": "0403",
},
{
"domain": "teleinfo",
"pid": "EA60",
"vid": "10C4",
},
{
"domain": "velbus",
"pid": "0B1B",
+6 -1
View File
@@ -1748,9 +1748,14 @@ def state_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType
async def async_validate_condition_config(
hass: HomeAssistant, config: ConfigType
hass: HomeAssistant, config: ConfigType | str
) -> ConfigType:
"""Validate config."""
if isinstance(config, str):
config = {
CONF_CONDITION: "template",
CONF_VALUE_TEMPLATE: cv.dynamic_template(config),
}
condition_key: str = config[CONF_CONDITION]
if condition_key in ("and", "not", "or"):
+18 -11
View File
@@ -1992,17 +1992,24 @@ _SCRIPT_SET_CONVERSATION_RESPONSE_SCHEMA = vol.Schema(
}
)
_SCRIPT_STOP_SCHEMA = vol.Schema(
{
**SCRIPT_ACTION_BASE_SCHEMA,
vol.Required(CONF_STOP): vol.Any(None, string),
vol.Exclusive(CONF_ERROR, "error_or_response"): boolean,
vol.Exclusive(
CONF_RESPONSE_VARIABLE,
"error_or_response",
msg="not allowed to add a response to an error stop action",
): str,
}
def _stop_action_check_error_response(config: dict) -> dict:
"""Validate that error stop actions don't have a response variable."""
if config.get(CONF_ERROR) and CONF_RESPONSE_VARIABLE in config:
raise vol.Invalid("not allowed to add a response to an error stop action")
return config
_SCRIPT_STOP_SCHEMA = vol.All(
vol.Schema(
{
**SCRIPT_ACTION_BASE_SCHEMA,
vol.Required(CONF_STOP): vol.Any(None, string),
vol.Optional(CONF_ERROR): boolean,
vol.Optional(CONF_RESPONSE_VARIABLE): str,
}
),
_stop_action_check_error_response,
)
_SCRIPT_SEQUENCE_SCHEMA = vol.Schema(
+40 -31
View File
@@ -36,6 +36,27 @@ from .context import (
template_context_manager,
template_cv,
)
from .extensions import (
AreaExtension,
Base64Extension,
CollectionExtension,
ConfigEntryExtension,
CryptoExtension,
DateTimeExtension,
DeviceExtension,
EntityExtension,
FloorExtension,
FunctionalExtension,
IssuesExtension,
LabelExtension,
MathExtension,
RegexExtension,
SerializationExtension,
StateExtension,
StringExtension,
TypeCastExtension,
VersionExtension,
)
from .helpers import result_as_boolean as result_as_boolean
from .render_info import RenderInfo, render_info_cv
from .states import (
@@ -722,37 +743,25 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
] = weakref.WeakValueDictionary()
self.add_extension("jinja2.ext.loopcontrols")
self.add_extension("jinja2.ext.do")
self.add_extension("homeassistant.helpers.template.extensions.AreaExtension")
self.add_extension("homeassistant.helpers.template.extensions.Base64Extension")
self.add_extension(
"homeassistant.helpers.template.extensions.CollectionExtension"
)
self.add_extension(
"homeassistant.helpers.template.extensions.ConfigEntryExtension"
)
self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension")
self.add_extension(
"homeassistant.helpers.template.extensions.DateTimeExtension"
)
self.add_extension("homeassistant.helpers.template.extensions.DeviceExtension")
self.add_extension("homeassistant.helpers.template.extensions.EntityExtension")
self.add_extension("homeassistant.helpers.template.extensions.FloorExtension")
self.add_extension(
"homeassistant.helpers.template.extensions.FunctionalExtension"
)
self.add_extension("homeassistant.helpers.template.extensions.IssuesExtension")
self.add_extension("homeassistant.helpers.template.extensions.LabelExtension")
self.add_extension("homeassistant.helpers.template.extensions.MathExtension")
self.add_extension("homeassistant.helpers.template.extensions.RegexExtension")
self.add_extension(
"homeassistant.helpers.template.extensions.SerializationExtension"
)
self.add_extension("homeassistant.helpers.template.extensions.StateExtension")
self.add_extension("homeassistant.helpers.template.extensions.StringExtension")
self.add_extension(
"homeassistant.helpers.template.extensions.TypeCastExtension"
)
self.add_extension("homeassistant.helpers.template.extensions.VersionExtension")
self.add_extension(AreaExtension)
self.add_extension(Base64Extension)
self.add_extension(CollectionExtension)
self.add_extension(ConfigEntryExtension)
self.add_extension(CryptoExtension)
self.add_extension(DateTimeExtension)
self.add_extension(DeviceExtension)
self.add_extension(EntityExtension)
self.add_extension(FloorExtension)
self.add_extension(FunctionalExtension)
self.add_extension(IssuesExtension)
self.add_extension(LabelExtension)
self.add_extension(MathExtension)
self.add_extension(RegexExtension)
self.add_extension(SerializationExtension)
self.add_extension(StateExtension)
self.add_extension(StringExtension)
self.add_extension(TypeCastExtension)
self.add_extension(VersionExtension)
if hass is not None:
# This environment has access to hass, attach its loader
+11 -5
View File
@@ -63,14 +63,14 @@ PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.33.1
securetar==2026.4.1
serialx==1.7.3
serialx==1.8.0
SQLAlchemy==2.0.49
standard-aifc==3.13.0
standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
urllib3>=2.0
uv==0.11.12
uv==0.11.13
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
@@ -107,9 +107,11 @@ enum34==1000000000.0.0
typing==1000000000.0.0
uuid==1000000000.0.0
# httpx requires httpcore, and httpcore requires anyio and h11, but the version constraints on
# these requirements are quite loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful for us to pin all these
# httpx requires httpcore, and httpcore requires anyio and h11,
# but the version constraints on these requirements are quite
# loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful
# for us to pin all these
# requirements so we can directly link HA versions to these library versions.
anyio==4.10.0
h11==0.16.0
@@ -176,6 +178,10 @@ charset-normalizer==3.4.3
# NAM, Brother, and GIOS.
dacite>=1.7.0
# decorator 5.3.0 dropped license metadata required by script/licenses.py.
# Pin to 5.2.1 until license metadata is restored.
decorator==5.2.1
# chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x
chacha20poly1305-reuseable>=0.13.0
Generated
+10
View File
@@ -3337,6 +3337,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.marantz_infrared.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.mastodon.*]
check_untyped_defs = true
disallow_incomplete_defs = true
+1 -1
View File
@@ -74,7 +74,7 @@ dependencies = [
"typing-extensions>=4.15.0,<5.0",
"ulid-transform==2.2.0",
"urllib3>=2.0",
"uv==0.11.12",
"uv==0.11.13",
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.3.0",
+2 -2
View File
@@ -47,7 +47,7 @@ python-slugify==8.0.4
PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.33.1
rf-protocols==3.0.0
rf-protocols==3.2.0
securetar==2026.4.1
SQLAlchemy==2.0.49
standard-aifc==3.13.0
@@ -55,7 +55,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.0
urllib3>=2.0
uv==0.11.12
uv==0.11.13
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
+10 -12
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.5.0
aioamazondevices==13.7.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -420,7 +420,7 @@ aiosolaredge==1.0.2
aiosteamist==1.0.1
# homeassistant.components.cambridge_audio
aiostreammagic==2.13.0
aiostreammagic==2.13.1
# homeassistant.components.switcher_kis
aioswitcher==6.1.1
@@ -1275,7 +1275,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.11.0
homematicip==2.12.0
# homeassistant.components.homevolt
homevolt==0.5.0
@@ -1959,7 +1959,7 @@ pyEmby==1.10
pyHik==0.4.2
# homeassistant.components.homee
pyHomee==1.3.8
pyHomee==1.4.0
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
@@ -1968,7 +1968,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.37.5
pyTibber==0.37.6
# homeassistant.components.dlink
pyW215==0.8.0
@@ -2219,7 +2219,7 @@ pyinsteon==1.6.4
pyintelliclima==0.3.1
# homeassistant.components.intesishome
pyintesishome==1.8.7
pyintesishome==1.8.8
# homeassistant.components.ipma
pyipma==3.0.9
@@ -2608,7 +2608,7 @@ python-digitalocean==1.13.2
python-dropbox-api==0.1.3
# homeassistant.components.duco
python-duco-connectivity==0.4.0
python-duco-connectivity==0.5.0
# homeassistant.components.ecobee
python-ecobee-api==0.3.2
@@ -2781,7 +2781,7 @@ pyvesync==3.4.1
pyvizio==0.1.61
# homeassistant.components.velux
pyvlx==0.2.33
pyvlx==0.2.34
# homeassistant.components.volumio
pyvolumio==0.1.5
@@ -2870,10 +2870,8 @@ renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.19.1
# homeassistant.components.honeywell_string_lights
# homeassistant.components.novy_cooker_hood
# homeassistant.components.radio_frequency
rf-protocols==3.0.0
rf-protocols==3.2.0
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2972,7 +2970,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.7.3
serialx==1.8.0
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
+1 -1
View File
@@ -10,7 +10,7 @@
# ast-serialize is an internal mypy dependency
ast-serialize==0.3.0
astroid==4.0.4
coverage==7.13.5
coverage==7.14.0
freezegun==1.5.5
# librt is an internal mypy dependency
librt==0.11.0
+9 -11
View File
@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.5.0
aioamazondevices==13.7.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -405,7 +405,7 @@ aiosolaredge==1.0.2
aiosteamist==1.0.1
# homeassistant.components.cambridge_audio
aiostreammagic==2.13.0
aiostreammagic==2.13.1
# homeassistant.components.switcher_kis
aioswitcher==6.1.1
@@ -1142,7 +1142,7 @@ homekit-audio-proxy==1.2.1
homelink-integration-api==0.0.1
# homeassistant.components.homematicip_cloud
homematicip==2.11.0
homematicip==2.12.0
# homeassistant.components.homevolt
homevolt==0.5.0
@@ -1708,13 +1708,13 @@ pyElectra==1.2.4
pyHik==0.4.2
# homeassistant.components.homee
pyHomee==1.3.8
pyHomee==1.4.0
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.37.5
pyTibber==0.37.6
# homeassistant.components.dlink
pyW215==0.8.0
@@ -2237,7 +2237,7 @@ python-citybikes==0.3.3
python-dropbox-api==0.1.3
# homeassistant.components.duco
python-duco-connectivity==0.4.0
python-duco-connectivity==0.5.0
# homeassistant.components.ecobee
python-ecobee-api==0.3.2
@@ -2380,7 +2380,7 @@ pyvesync==3.4.1
pyvizio==0.1.61
# homeassistant.components.velux
pyvlx==0.2.33
pyvlx==0.2.34
# homeassistant.components.volumio
pyvolumio==0.1.5
@@ -2457,10 +2457,8 @@ renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.19.1
# homeassistant.components.honeywell_string_lights
# homeassistant.components.novy_cooker_hood
# homeassistant.components.radio_frequency
rf-protocols==3.0.0
rf-protocols==3.2.0
# homeassistant.components.rflink
rflink==0.0.67
@@ -2541,7 +2539,7 @@ sentry-sdk==2.48.0
# homeassistant.components.acer_projector
# homeassistant.components.serial
# homeassistant.components.usb
serialx==1.7.3
serialx==1.8.0
# homeassistant.components.sfr_box
sfrbox-api==0.1.1
+13 -6
View File
@@ -14,9 +14,10 @@ from typing import Any
from homeassistant.util.yaml.loader import load_yaml
from script.hassfest.model import Config, Integration
# Requirements which can't be installed on all systems because they rely on additional
# system packages. Requirements listed in EXCLUDED_REQUIREMENTS_ALL will be commented-out
# in requirements_all.txt and requirements_test_all.txt.
# Requirements which can't be installed on all systems because they
# rely on additional system packages. Requirements listed in
# EXCLUDED_REQUIREMENTS_ALL will be commented-out in
# requirements_all.txt and requirements_test_all.txt.
EXCLUDED_REQUIREMENTS_ALL = {
"atenpdu", # depends on pysnmp which is not maintained at this time
"avion",
@@ -90,9 +91,11 @@ enum34==1000000000.0.0
typing==1000000000.0.0
uuid==1000000000.0.0
# httpx requires httpcore, and httpcore requires anyio and h11, but the version constraints on
# these requirements are quite loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful for us to pin all these
# httpx requires httpcore, and httpcore requires anyio and h11,
# but the version constraints on these requirements are quite
# loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful
# for us to pin all these
# requirements so we can directly link HA versions to these library versions.
anyio==4.10.0
h11==0.16.0
@@ -159,6 +162,10 @@ charset-normalizer==3.4.3
# NAM, Brother, and GIOS.
dacite>=1.7.0
# decorator 5.3.0 dropped license metadata required by script/licenses.py.
# Pin to 5.2.1 until license metadata is restored.
decorator==5.2.1
# chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x
chacha20poly1305-reuseable>=0.13.0
+4 -1
View File
@@ -123,7 +123,10 @@ def get_config() -> Config:
"--skip-plugins",
type=validate_plugins,
default=[],
help=f"Comma-separated list of plugins to skip. Valid plugin names: {ALL_PLUGIN_NAMES}",
help=(
"Comma-separated list of plugins to skip."
f" Valid plugin names: {ALL_PLUGIN_NAMES}"
),
)
parser.add_argument(
"--core-path",
+2 -1
View File
@@ -35,7 +35,8 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
if application_credentials_path.read_text(encoding="utf-8") != content:
config.add_error(
"application_credentials",
"File application_credentials.py is not up to date. Run python3 -m script.hassfest",
"File application_credentials.py is not up to date."
" Run python3 -m script.hassfest",
fixable=True,
)
+2 -1
View File
@@ -54,7 +54,8 @@ def _validate_brand(
if sub_integration not in integrations:
config.add_error(
"brand",
f"{brand.path.name}: References unknown integration {sub_integration}",
f"{brand.path.name}: References unknown"
f" integration {sub_integration}",
)
if brand.domain in integrations and (
+45 -18
View File
@@ -47,15 +47,21 @@ def validate_field_schema(condition_schema: dict[str, Any]) -> dict[str, Any]:
# Check if context key is allowed for this selector type
allowed_keys = selector_class.allowed_context_keys
if context_key not in allowed_keys:
allowed = (
", ".join(sorted(allowed_keys)) if allowed_keys else "none"
)
raise vol.Invalid(
f"Invalid context key '{context_key}' for selector type '{selector_class.selector_type}'. "
f"Allowed keys: {', '.join(sorted(allowed_keys)) if allowed_keys else 'none'}"
f"Invalid context key '{context_key}'"
f" for selector type"
f" '{selector_class.selector_type}'."
f" Allowed keys: {allowed}"
)
# Check if the referenced field exists in condition schema or target
if not isinstance(field_ref, str):
raise vol.Invalid(
f"Context value for '{context_key}' must be a string field reference"
f"Context value for '{context_key}'"
" must be a string field reference"
)
# Check if field exists in condition schema fields or target
@@ -68,9 +74,15 @@ def validate_field_schema(condition_schema: dict[str, Any]) -> dict[str, Any]:
if field_selector_class.selector_type not in allowed_keys.get(
context_key, set()
):
allowed_types = ", ".join(allowed_keys.get(context_key, set()))
sel_type = field_selector_class.selector_type
raise vol.Invalid(
f"The context '{context_key}' for '{field_name}' references '{field_ref}', but '{context_key}' "
f"does not allow selectors of type '{field_selector_class.selector_type}'. Allowed selector types: {', '.join(allowed_keys.get(context_key, set()))}"
f"The context '{context_key}' for"
f" '{field_name}' references"
f" '{field_ref}', but"
f" '{context_key}' does not allow"
f" selectors of type '{sel_type}'."
f" Allowed types: {allowed_types}"
)
if not field_exists and "target" in condition_schema:
# Target is a special field that always exists when defined
@@ -78,15 +90,21 @@ def validate_field_schema(condition_schema: dict[str, Any]) -> dict[str, Any]:
if field_exists and "target" not in allowed_keys.get(
context_key, set()
):
allowed_types = ", ".join(allowed_keys.get(context_key, set()))
raise vol.Invalid(
f"The context '{context_key}' for '{field_name}' references 'target', but '{context_key}' "
f"does not allow 'target'. Allowed selector types: {', '.join(allowed_keys.get(context_key, set()))}"
f"The context '{context_key}' for"
f" '{field_name}' references"
f" 'target', but '{context_key}'"
" does not allow 'target'."
f" Allowed types: {allowed_types}"
)
if not field_exists:
raise vol.Invalid(
f"Context reference '{field_ref}' for key '{context_key}' does not exist "
f"in condition schema fields or target"
f"Context reference '{field_ref}'"
f" for key '{context_key}' does"
" not exist in condition schema"
" fields or target"
)
return condition_schema
@@ -98,9 +116,9 @@ FIELD_SCHEMA = vol.Schema(
vol.Optional("default"): exists,
vol.Optional("required"): bool,
vol.Optional(CONF_SELECTOR): selector.validate_selector,
vol.Optional("context"): {
str: str # key is context key, value is field name in the schema which value should be used
}, # Will be validated in validate_field_schema
# key is context key, value is field name in schema
# Validated in validate_field_schema
vol.Optional("context"): {str: str},
}
)
@@ -241,10 +259,9 @@ def validate_conditions(config: Config, integration: Integration) -> None: # no
except KeyError:
integration.add_error(
"conditions",
(
f"Condition {condition_name} has a field {field_name} with no "
f"name {error_msg_suffix}"
),
f"Condition {condition_name} has a"
f" field {field_name} with no"
f" name {error_msg_suffix}",
)
if "selector" in field_schema:
@@ -257,7 +274,14 @@ def validate_conditions(config: Config, integration: Integration) -> None: # no
except KeyError:
integration.add_error(
"conditions",
f"Condition {condition_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file",
f"Condition {condition_name}"
f" has a field"
f" {field_name} with a"
" selector with a"
" translation key"
f" {translation_key}"
" that is not in the"
" translations file",
)
# The same check is done for the description in each of the sections of the
@@ -274,7 +298,10 @@ def validate_conditions(config: Config, integration: Integration) -> None: # no
except KeyError:
integration.add_error(
"conditions",
f"Condition {condition_name} has a section {section_name} with no name {error_msg_suffix}",
f"Condition {condition_name}"
f" has a section"
f" {section_name} with no"
f" name {error_msg_suffix}",
)
+4 -3
View File
@@ -128,9 +128,10 @@ def _generate_integrations(
"translated_name": set(),
}
# Not all integrations will have an item in the brands collection.
# The config flow data index will be the union of the integrations without a brands item
# and the brand domain names from the brands collection.
# Not all integrations will have an item in the brands
# collection. The config flow data index will be the union of
# the integrations without a brands item and the brand domain
# names from the brands collection.
# Compile a set of integrations which are referenced from at least one brand's
# integrations list. These integrations will not be present in the root level of the
+9 -3
View File
@@ -70,7 +70,8 @@ class ImportCollector(ast.NodeVisitor):
return
if node.module.startswith("homeassistant.components."):
# from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME
# from homeassistant.components.alexa.smart_home
# import EVENT_ALEXA_SMART_HOME
# from homeassistant.components.logbook import bla
self._add_reference(node.module.split(".")[2])
@@ -279,7 +280,9 @@ def _check_circular_deps(
if domain == start_domain:
integrations[start_domain].add_error(
"dependencies",
f"Found a circular dependency with {integration.domain} ({', '.join(checking)})",
f"Found a circular dependency with"
f" {integration.domain}"
f" ({', '.join(checking)})",
)
break
@@ -291,7 +294,10 @@ def _check_circular_deps(
if domain == start_domain:
integrations[start_domain].add_error(
"dependencies",
f"Found a circular dependency with after dependencies of {integration.domain} ({', '.join(checking)})",
f"Found a circular dependency"
" with after dependencies of"
f" {integration.domain}"
f" ({', '.join(checking)})",
)
break
+2 -1
View File
@@ -18,7 +18,8 @@ def icon_value_validator(value: Any) -> str:
value = cv.string_with_no_html(value)
if not value.startswith("mdi:"):
raise vol.Invalid(
"The icon needs to be a valid icon from Material Design Icons and start with `mdi:`"
"The icon needs to be a valid icon from Material"
" Design Icons and start with `mdi:`"
)
return str(value)
+5 -2
View File
@@ -82,7 +82,9 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
if integration.domain in MISSING_INTEGRATION_TYPE:
integration.add_error(
"integration_type",
"Integration has an `integration_type` in the manifest but is still listed in MISSING_INTEGRATION_TYPE",
"Integration has an `integration_type`"
" in the manifest but is still listed"
" in MISSING_INTEGRATION_TYPE",
)
continue
@@ -91,5 +93,6 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
integration.add_error(
"integration_type",
"Integration has a config flow but is missing an `integration_type` in the manifest",
"Integration has a config flow but is missing"
" an `integration_type` in the manifest",
)
+8 -3
View File
@@ -18,7 +18,8 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str:
if not isinstance(preview_features, dict):
integration.add_error(
"labs",
f"preview_features must be a dict, got {type(preview_features).__name__}",
"preview_features must be a dict,"
f" got {type(preview_features).__name__}",
)
continue
@@ -28,13 +29,17 @@ def generate_and_validate(integrations: dict[str, Integration]) -> str:
if not isinstance(preview_feature_id, str):
integration.add_error(
"labs",
f"preview_features keys must be strings, got {type(preview_feature_id).__name__}",
"preview_features keys must be"
" strings, got"
f" {type(preview_feature_id).__name__}",
)
break
if not isinstance(preview_feature_config, dict):
integration.add_error(
"labs",
f"preview_features[{preview_feature_id}] must be a dict, got {type(preview_feature_config).__name__}",
f"preview_features[{preview_feature_id}]"
" must be a dict, got"
f" {type(preview_feature_config).__name__}",
)
break
# Include the full feature configuration
+2 -1
View File
@@ -25,7 +25,8 @@ def validate(integrations: dict[str, Integration], config: Config) -> None:
if data["project"]["requires-python"] != required_py_version:
config.add_error(
"metadata",
f"'project.requires-python' value doesn't match '{required_py_version}'",
"'project.requires-python' value doesn't"
f" match '{required_py_version}'",
)
except KeyError:
config.add_error("metadata", "No 'options.python_requires' key found!")
+22 -7
View File
@@ -2184,16 +2184,23 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
integration.add_error(
"quality_scale",
(
"New integrations marked as internal should be added to NO_QUALITY_SCALE in script/hassfest/quality_scale.py."
"New integrations marked as internal"
" should be added to NO_QUALITY_SCALE"
" in script/hassfest/quality_scale.py."
if integration.quality_scale == "internal"
else "Quality scale definition not found. New integrations are required to at least reach the Bronze tier."
else "Quality scale definition not found."
" New integrations are required to at"
" least reach the Bronze tier."
),
)
return
if declared_quality_scale is not None:
integration.add_error(
"quality_scale",
"Quality scale definition not found. Integrations that set a manifest quality scale must have a quality scale definition.",
"Quality scale definition not found."
" Integrations that set a manifest quality"
" scale must have a quality scale"
" definition.",
)
return
return
@@ -2212,7 +2219,9 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
if integration.domain in INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE:
integration.add_error(
"quality_scale",
"Quality scale file found! Please remove from `INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE`"
"Quality scale file found! Please"
" remove from"
" `INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE`"
" in script/hassfest/quality_scale.py",
)
return
@@ -2222,7 +2231,8 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
):
integration.add_error(
"quality_scale",
"This integration is graded and should be removed from `INTEGRATIONS_WITHOUT_SCALE`"
"This integration is graded and should be"
" removed from `INTEGRATIONS_WITHOUT_SCALE`"
" in script/hassfest/quality_scale.py",
)
return
@@ -2233,7 +2243,10 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
integration.add_error(
"quality_scale",
(
"New integrations marked as internal should be added to INTEGRATIONS_WITHOUT_SCALE in script/hassfest/quality_scale.py."
"New integrations marked as internal"
" should be added to"
" INTEGRATIONS_WITHOUT_SCALE in"
" script/hassfest/quality_scale.py."
if integration.quality_scale == "internal"
else "New integrations are required to at least reach the Bronze tier."
),
@@ -2287,7 +2300,9 @@ def validate_iqs_file(config: Config, integration: Integration) -> None:
)
integration.add_error(
"quality_scale",
f"Quality scale tier {scale.name.lower()} requires quality scale rules to be met:\n{friendly_rule_str}",
f"Quality scale tier {scale.name.lower()}"
" requires quality scale rules to be"
f" met:\n{friendly_rule_str}",
)
+6 -3
View File
@@ -660,8 +660,10 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]:
):
integration.add_error(
"requirements",
f"Integration {integration.domain} runtime files dependency exceptions "
"have been resolved, please remove from `FORBIDDEN_PACKAGE_FILES_EXCEPTIONS`",
f"Integration {integration.domain} runtime"
" files dependency exceptions have been"
" resolved, please remove from"
" `FORBIDDEN_PACKAGE_FILES_EXCEPTIONS`",
)
return all_requirements
@@ -767,7 +769,8 @@ def check_dependency_files(
integration.add_warning_or_error(
pkg in package_exceptions,
"requirements",
f"Package {pkg} has a forbidden top level directory '{dir_name}' in {package}",
f"Package {pkg} has a forbidden top level"
f" directory '{dir_name}' in {package}",
)
for file_name in results["file_names"]:
integration.add_warning_or_error(

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