mirror of
https://github.com/home-assistant/core.git
synced 2026-05-20 07:45:09 +02:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03e05083a8 | |||
| e76e923f26 | |||
| 8854ad5765 | |||
| 0eecb03b84 | |||
| 0c22c13b1f | |||
| bf56fad3f9 | |||
| 078d40ac54 | |||
| 1b7bda06d3 | |||
| 828dde26e5 | |||
| e8d21e57b3 | |||
| 481965eb0d | |||
| 2fcfa8320f | |||
| 95c68da115 | |||
| d547076033 | |||
| db0006c100 | |||
| f8d4826bf3 | |||
| 88f6b7159a | |||
| f7faed7330 | |||
| 302148b078 | |||
| 5b2816e56c | |||
| f7cf279648 | |||
| ee83a14391 | |||
| 833ff982d0 | |||
| d8cb3ab4b8 | |||
| 23b0f550b1 | |||
| c66eeed8f8 | |||
| bdc9d881ea | |||
| 95e2f5e219 | |||
| 68fc5c0e87 | |||
| 67c1930c6f | |||
| c90017d207 | |||
| 9dce6943de | |||
| 6a5faf2ec7 | |||
| d0711624c0 | |||
| 03ea95dfd4 | |||
| 721c736c03 | |||
| 1c105a5766 | |||
| ad0324631b | |||
| 83fbea2158 | |||
| 74c918b6b6 | |||
| ff7964bcfc | |||
| 9a1fd913bf | |||
| f0396aca8a | |||
| 018e3a4765 | |||
| 2af7f43ed7 | |||
| 95878222fd | |||
| 95f3bd7c09 | |||
| c366beab2e | |||
| 88277d5920 | |||
| 5e0aefd539 | |||
| ff313f1e7f | |||
| 70f9395d02 | |||
| b96f904d15 | |||
| 0d16fa1e65 | |||
| 27816fcb0c | |||
| 4f0faf43c6 | |||
| c28f5d3eed | |||
| 7b589d6ce8 | |||
| b5556e17b2 | |||
| 407d29396a | |||
| 7eaa132189 | |||
| 87b151a436 | |||
| 19cbb3e5c9 | |||
| 675bbd704c | |||
| 30a51e643f | |||
| 6ae50fffe1 | |||
| e60704ccec | |||
| ad9a7c08ab | |||
| 198cb331ed | |||
| fc8949d4a2 | |||
| ed74360485 | |||
| 9109cb5bfb | |||
| 5c29580969 | |||
| 3813843c8c | |||
| 5ac7f898dd |
@@ -128,7 +128,8 @@
|
||||
"home-assistant-bluetooth",
|
||||
"home-assistant-frontend",
|
||||
"home-assistant-intents",
|
||||
"infrared-protocols"
|
||||
"infrared-protocols",
|
||||
"rf-protocols"
|
||||
],
|
||||
"enabled": true,
|
||||
"minimumReleaseAge": null,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+1
@@ -430,6 +430,7 @@ FLOWS = {
|
||||
"lyric",
|
||||
"madvr",
|
||||
"mailgun",
|
||||
"marantz_infrared",
|
||||
"mastodon",
|
||||
"matter",
|
||||
"mcp",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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
|
||||
|
||||
Generated
+10
-12
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+9
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user