Compare commits

...

73 Commits

Author SHA1 Message Date
Franck Nijhof a8630f5570 Add delegated charging mode to Renault integration (#174687) 2026-06-24 23:35:13 +02:00
J. Nick Koston 2a75b0e2fb Bump habluetooth to 6.24.0 (#174688) 2026-06-24 23:34:38 +02:00
Brandon Rothweiler 9c4ad761c4 Add missing scope and authorize param to Dropbox OAuth (#174587) 2026-06-24 22:26:30 +02:00
Erwin Douna 8e3e1044a1 Tami4 group executor job (#174668) 2026-06-24 22:01:24 +02:00
Colin bec6c94e32 openevse: Convert config to textselector (#174675) 2026-06-24 21:50:41 +02:00
Colin c9729df69a openevse: Add missing callback test (#174560) 2026-06-24 21:33:30 +02:00
Christian Lackas 70ff0fd682 Bump homematicip to 2.13.2 (#174673) 2026-06-24 20:43:23 +02:00
Erwin Douna 258ae6d506 Vera core group executor job (#174669) 2026-06-24 20:37:08 +02:00
Ville Skyttä 4f93afd6ae Remove myself from huawei_lte codeowners (#174671) 2026-06-24 19:57:24 +02:00
Erwin Douna 7968fc4809 Huawei group executor job (#174666) 2026-06-24 19:43:54 +02:00
TheJulianJES 975f2a831e Bump zha-quirks to 2.1.0 (#174662) 2026-06-24 18:42:41 +02:00
Leonardo Merza cc2944d626 Fix ecobee active sensor reporting for custom presets and shared device names (#174417) 2026-06-24 17:21:45 +01:00
Mick Vleeshouwer 548ec5cacf Enable action queue to batch concurrent commands in Overkiz (#174275) 2026-06-24 17:19:15 +02:00
karwosts dc6eef2844 Fix date-only input_datetime timestamp attribute to use the correct TZ (#174357) 2026-06-24 17:15:09 +02:00
Stefan Agner 0808e30e37 Add repair when IPv6 is disabled for Matter (#174653)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-24 17:11:54 +02:00
Manu f0ed257f47 Refactor Steam integration config flow and tests (#174504)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-24 17:06:29 +02:00
Petro31 b4b710b474 Add restore state framework for template entities (#172847) 2026-06-24 16:54:56 +02:00
Bram Kragten 0004a82fe4 Update frontend to 20260624.0 (#174657) 2026-06-24 15:46:44 +01:00
epenet 0c4bc95bdd Migrate base entity attributes to StrEnum (#174633) 2026-06-24 15:38:49 +01:00
Manu 5fdab795e8 Remove Avi-on integration (#174649) 2026-06-24 16:35:22 +02:00
Manu 2193665909 Remove BeeWi SmartClim (#174651) 2026-06-24 16:32:05 +02:00
Martin Hjelmare c9d91d5812 Add enabled entity limit per config entry (#174194) 2026-06-24 16:07:54 +02:00
Erik Montnemery de9d9c66c1 Add additional sun conditions (#174537) 2026-06-24 16:00:08 +02:00
Paul Bottein dfcc4d1ae4 Fix friendly name for restored unavailable entities (#174614) 2026-06-24 14:47:39 +01:00
TimL d71812f09b Bump pysmlight to 0.4.0 (#174640) 2026-06-24 15:19:35 +02:00
Ariel Ebersberger a323ebe634 Reword "advanced operation" channel warning in Home Assistant Hardware (#174645) 2026-06-24 14:57:18 +02:00
Ariel Ebersberger 024bba55cf Reword "Configure advanced voice settings" in ElevenLabs (#174642) 2026-06-24 14:33:41 +02:00
Ariel Ebersberger a5546566e7 Rename "(advanced)" service names in Z-Wave (#174644) 2026-06-24 14:32:38 +02:00
Ariel Ebersberger 3d9994ee4f Rename "Local Risco Panel (advanced)" option in Risco (#174643) 2026-06-24 14:31:55 +02:00
Erik Montnemery c542f38387 Add checks for did_not_trigger calls to trigger tests (#174636)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:05:08 +01:00
wollew 49d6166b7e Bump pyvlx to 0.2.36 (#174638) 2026-06-24 13:21:45 +02:00
J. Nick Koston 7249190c64 Bump habluetooth to 6.23.1 (#174639) 2026-06-24 13:21:12 +02:00
epenet cebdde6ab4 Migrate device tracker entity attributes to StrEnum (#174621) 2026-06-24 12:53:43 +02:00
epenet 031f4cd965 Migrate remaining platform entity attributes to StrEnum (#174625) 2026-06-24 12:52:50 +02:00
Ariel Ebersberger a734f7110c Rename "Advanced options" to "Additional options" in DNS IP (#174628) 2026-06-24 12:38:16 +02:00
Ariel Ebersberger 395e949591 Rename "Advanced settings" to "Additional settings" in History Stats (#174629) 2026-06-24 12:38:00 +02:00
Ariel Ebersberger 484e60a1c4 Rename "Advanced options" to "Additional options" in SQL (#174631) 2026-06-24 12:19:38 +02:00
Ariel Ebersberger b7a234fbd9 Rename "Advanced settings" to "Additional settings" in Telegram bot (#174632) 2026-06-24 12:19:08 +02:00
Ariel Ebersberger a1982fbd54 Rename "Advanced settings" to "Additional settings" in Autoskope (#174630) 2026-06-24 12:18:53 +02:00
epenet c384cd9894 Migrate calendar entity attributes to StrEnum (#174615) 2026-06-24 12:10:37 +02:00
Tom 1aefd2a5ac Bump airOS dependency to support open wireless (#174559) 2026-06-24 12:00:38 +02:00
epenet e3605be5cd Migrate siren entity attributes to StrEnum (#174616) 2026-06-24 11:54:13 +02:00
epenet e87a41a01d Migrate text entity attributes to StrEnum (#174619) 2026-06-24 11:51:28 +02:00
epenet 190ff034aa Migrate vacuum entity attributes to StrEnum (#174617) 2026-06-24 11:46:29 +02:00
epenet b301925687 Migrate cover entity attributes to StrEnum (#174601) 2026-06-24 11:18:37 +02:00
epenet 7a0f5b066e Migrate humidifier entity attributes to StrEnum (#174609) 2026-06-24 11:17:45 +02:00
epenet 308fad166d Migrate media player entity attributes to StrEnum (#174605) 2026-06-24 11:16:33 +02:00
epenet 1305c2978c Migrate fan entity attributes to StrEnum (#174610) 2026-06-24 11:12:45 +02:00
epenet 955ad6db1b Migrate valve entity attributes to StrEnum (#174611) 2026-06-24 11:12:02 +02:00
Andreas Schneider 87dc013803 Use age-based filter for Matter BLE advertisement history replay (#173488) 2026-06-24 04:11:29 -05:00
Stefan Agner 1bb41cb2dd Use translated message when Matter Server add-on is not ready (#174529)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:02:08 +02:00
epenet 277af6c60b Migrate update entity attributes to StrEnum (#174608) 2026-06-24 10:57:58 +02:00
Raphael Hehl 69e18aa580 Source UniFi Protect light auto-shutoff duration from the public API (#174518) 2026-06-24 03:54:38 -05:00
Raphael Hehl 75852fc191 Add ufp_public_enabled_fn for public-API availability gating to UniFi Protect (#174544) 2026-06-24 03:53:49 -05:00
Mark Ruys a661b678a2 Add Sensoterra CODEOWNERs (#174431)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-24 10:53:36 +02:00
Raphael Hehl bd0951110d Drive the UniFi Protect doorbell ring event from the public events websocket (#174546) 2026-06-24 03:53:01 -05:00
epenet 899f904cf3 Migrate weather entity attributes to StrEnum (#174604) 2026-06-24 10:38:25 +02:00
epenet d2e7426aa5 Migrate image entity attributes to StrEnum (#174606) 2026-06-24 10:37:38 +02:00
epenet c0e02457bc Migrate lock entity attributes to StrEnum (#174607) 2026-06-24 10:37:11 +02:00
epenet e7562b50cf Migrate water heater entity attributes to StrEnum (#174603) 2026-06-24 10:32:38 +02:00
Thomas c36e4a03e0 Bump boschshcpy to 0.3.5 (#174550) 2026-06-24 10:16:45 +02:00
Nathan Spencer 71430af6ff Bump pylitterbot to 2025.5.0 (#174554) 2026-06-24 10:15:43 +02:00
Åke Strandberg 815cce5a0c Tweak aqvify entity names (#174597) 2026-06-24 10:08:15 +02:00
Robert Svensson 32929755eb Tighten the Axis unique ID in config flow (#172283) 2026-06-24 10:07:10 +02:00
Manu 88d4d1c879 Remove SCSGate integration (#174571) 2026-06-24 10:02:23 +02:00
Manu 51bd71d096 Remove Acer projector integration (#174579) 2026-06-24 09:58:09 +02:00
Thomas 1fcf9eb5b7 Add @mosandlt as codeowner for bosch_shc (#174563) 2026-06-24 09:57:19 +02:00
epenet 1917a007f8 Migrate event entity attributes to StrEnum (#174592) 2026-06-24 09:56:20 +02:00
Paul Bottein b095baa65a Bump Yoto quality scale to platinum (#174598) 2026-06-24 09:52:20 +02:00
Przemysław Szypowicz 2bd81c7351 Remove broken Ampio Smog integration (#173080) 2026-06-24 09:50:46 +02:00
Paul Bottein a576aef9a4 Add dynamic and stale device handling to Yoto (#173298)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-24 09:32:10 +02:00
Michael Hansen c2e780dfd2 Improve Wyoming satellite reconnect and tolerance of other satellites (#174460)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:22:03 +02:00
epenet 687064d5cc Migrate Ratio units to StrEnum (#172568) 2026-06-24 09:21:28 +02:00
1391 changed files with 45662 additions and 43690 deletions
-2
View File
@@ -43,7 +43,6 @@ homeassistant.components
homeassistant.components.abode.*
homeassistant.components.acaia.*
homeassistant.components.accuweather.*
homeassistant.components.acer_projector.*
homeassistant.components.acmeda.*
homeassistant.components.actiontec.*
homeassistant.components.actron_air.*
@@ -77,7 +76,6 @@ homeassistant.components.amberelectric.*
homeassistant.components.ambient_network.*
homeassistant.components.ambient_station.*
homeassistant.components.amcrest.*
homeassistant.components.ampio.*
homeassistant.components.analytics.*
homeassistant.components.analytics_insights.*
homeassistant.components.android_ip_webcam.*
Generated
+6 -7
View File
@@ -230,7 +230,6 @@ CLAUDE.md @home-assistant/core
/tests/components/battery/ @home-assistant/core
/homeassistant/components/bayesian/ @HarvsG
/tests/components/bayesian/ @HarvsG
/homeassistant/components/beewi_smartclim/ @alemuro
/homeassistant/components/binary_sensor/ @home-assistant/core
/tests/components/binary_sensor/ @home-assistant/core
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
@@ -254,8 +253,8 @@ CLAUDE.md @home-assistant/core
/tests/components/bond/ @bdraco @prystupa @joshs85 @marciogranzotto
/homeassistant/components/bosch_alarm/ @mag1024 @sanjay900
/tests/components/bosch_alarm/ @mag1024 @sanjay900
/homeassistant/components/bosch_shc/ @tschamm
/tests/components/bosch_shc/ @tschamm
/homeassistant/components/bosch_shc/ @tschamm @mosandlt
/tests/components/bosch_shc/ @tschamm @mosandlt
/homeassistant/components/brands/ @home-assistant/core
/tests/components/brands/ @home-assistant/core
/homeassistant/components/braviatv/ @bieniu @Drafteed
@@ -791,8 +790,8 @@ CLAUDE.md @home-assistant/core
/tests/components/html5/ @alexyao2015 @tr4nt0r
/homeassistant/components/http/ @home-assistant/core
/tests/components/http/ @home-assistant/core
/homeassistant/components/huawei_lte/ @scop @fphammerle
/tests/components/huawei_lte/ @scop @fphammerle
/homeassistant/components/huawei_lte/ @fphammerle
/tests/components/huawei_lte/ @fphammerle
/homeassistant/components/hue/ @marcelveldt
/tests/components/hue/ @marcelveldt
/homeassistant/components/hue_ble/ @flip-dots
@@ -1603,8 +1602,8 @@ CLAUDE.md @home-assistant/core
/tests/components/sensorpush/ @bdraco
/homeassistant/components/sensorpush_cloud/ @sstallion
/tests/components/sensorpush_cloud/ @sstallion
/homeassistant/components/sensoterra/ @markruys
/tests/components/sensoterra/ @markruys
/homeassistant/components/sensoterra/ @SanderBakkumCuriousInc @curious-florian @markruys
/tests/components/sensoterra/ @SanderBakkumCuriousInc @curious-florian @markruys
/homeassistant/components/sentry/ @dcramer @frenck
/tests/components/sentry/ @dcramer @frenck
/homeassistant/components/senz/ @milanmeu
@@ -1 +0,0 @@
"""The acer_projector component."""
@@ -1,34 +0,0 @@
"""Use serial protocol of Acer projector to obtain state of the projector."""
from typing import Final
from homeassistant.const import STATE_OFF, STATE_ON
CONF_READ_TIMEOUT: Final = "timeout"
CONF_WRITE_TIMEOUT: Final = "write_timeout"
DEFAULT_NAME: Final = "Acer Projector"
DEFAULT_READ_TIMEOUT: Final = 1
DEFAULT_WRITE_TIMEOUT: Final = 1
ECO_MODE: Final = "ECO Mode"
ICON: Final = "mdi:projector"
INPUT_SOURCE: Final = "Input Source"
LAMP: Final = "Lamp"
LAMP_HOURS: Final = "Lamp Hours"
MODEL: Final = "Model"
# Commands known to the projector
CMD_DICT: Final[dict[str, str]] = {
LAMP: "* 0 Lamp ?\r",
LAMP_HOURS: "* 0 Lamp\r",
INPUT_SOURCE: "* 0 Src ?\r",
ECO_MODE: "* 0 IR 052\r",
MODEL: "* 0 IR 035\r",
STATE_ON: "* 0 IR 001\r",
STATE_OFF: "* 0 IR 002\r",
}
@@ -1,9 +0,0 @@
{
"domain": "acer_projector",
"name": "Acer Projector",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.8.2"]
}
@@ -1,155 +0,0 @@
"""Use serial protocol of Acer projector to obtain state of the projector."""
import logging
import re
from typing import Any, override
from serialx import Serial, SerialException
import voluptuous as vol
from homeassistant.components.switch import (
PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA,
SwitchEntity,
)
from homeassistant.const import (
CONF_FILENAME,
CONF_NAME,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
CMD_DICT,
CONF_READ_TIMEOUT,
CONF_WRITE_TIMEOUT,
DEFAULT_NAME,
DEFAULT_READ_TIMEOUT,
DEFAULT_WRITE_TIMEOUT,
ECO_MODE,
ICON,
INPUT_SOURCE,
LAMP,
LAMP_HOURS,
)
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_FILENAME): cv.isdevice,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_READ_TIMEOUT, default=DEFAULT_READ_TIMEOUT): cv.positive_int,
vol.Optional(
CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT
): cv.positive_int,
}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Connect with serial port and return Acer Projector."""
serial_port = config[CONF_FILENAME]
name = config[CONF_NAME]
read_timeout = config[CONF_READ_TIMEOUT]
write_timeout = config[CONF_WRITE_TIMEOUT]
add_entities([AcerSwitch(serial_port, name, read_timeout, write_timeout)], True)
class AcerSwitch(SwitchEntity):
"""Represents an Acer Projector as a switch."""
_attr_icon = ICON
def __init__(
self,
serial_port: str,
name: str,
read_timeout: int,
write_timeout: int,
) -> None:
"""Init of the Acer projector."""
self._serial_port = serial_port
self._read_timeout = read_timeout
self._write_timeout = write_timeout
self._attr_name = name
self._attributes = {
LAMP_HOURS: STATE_UNKNOWN,
INPUT_SOURCE: STATE_UNKNOWN,
ECO_MODE: STATE_UNKNOWN,
}
def _write_read(self, msg: str) -> str:
"""Write to the projector and read the return."""
# Sometimes the projector won't answer for no reason or the projector
# was disconnected during runtime.
# This way the projector can be reconnected and will still work
try:
with Serial.from_url(
self._serial_port,
read_timeout=self._read_timeout,
write_timeout=self._write_timeout,
) as serial:
serial.write(msg.encode("utf-8"))
# Size is an experience value there is no real limit.
# AFAIK there is no limit and no end character so we will usually
# need to wait for timeout
return serial.read_until(size=20).decode("utf-8")
except (OSError, SerialException, TimeoutError) as exc:
raise HomeAssistantError(
f"Problem communicating with {self._serial_port}"
) from exc
def _write_read_format(self, msg: str) -> str:
"""Write msg, obtain answer and format output."""
# answers are formatted as ***\answer\r***
awns = self._write_read(msg)
if match := re.search(r"\r(.+)\r", awns):
return match.group(1)
return STATE_UNKNOWN
def update(self) -> None:
"""Get the latest state from the projector."""
awns = self._write_read_format(CMD_DICT[LAMP])
if awns == "Lamp 1":
self._attr_is_on = True
self._attr_available = True
elif awns == "Lamp 0":
self._attr_is_on = False
self._attr_available = True
else:
self._attr_available = False
for key in self._attributes:
if msg := CMD_DICT.get(key):
awns = self._write_read_format(msg)
self._attributes[key] = awns
self._attr_extra_state_attributes = self._attributes
@override
def turn_on(self, **kwargs: Any) -> None:
"""Turn the projector on."""
msg = CMD_DICT[STATE_ON]
self._write_read(msg)
self._attr_is_on = True
@override
def turn_off(self, **kwargs: Any) -> None:
"""Turn the projector off."""
msg = CMD_DICT[STATE_OFF]
self._write_read(msg)
self._attr_is_on = False
+1 -1
View File
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["airos==0.6.8"]
"requirements": ["airos==0.6.9"]
}
@@ -1 +0,0 @@
"""The Ampio component."""
@@ -1,105 +0,0 @@
"""Support for Ampio Air Quality data."""
import logging
from typing import Final, override
from asmog import AmpioSmog
import voluptuous as vol
from homeassistant.components.air_quality import (
PLATFORM_SCHEMA as AIR_QUALITY_PLATFORM_SCHEMA,
AirQualityEntity,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle
from .const import CONF_STATION_ID, SCAN_INTERVAL
_LOGGER: Final = logging.getLogger(__name__)
PLATFORM_SCHEMA: Final = AIR_QUALITY_PLATFORM_SCHEMA.extend(
{vol.Required(CONF_STATION_ID): cv.string, vol.Optional(CONF_NAME): cv.string}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Ampio Smog air quality platform."""
name = config.get(CONF_NAME)
station_id = config[CONF_STATION_ID]
session = async_get_clientsession(hass)
api = AmpioSmogMapData(AmpioSmog(station_id, hass.loop, session))
await api.async_update()
if not api.api.data:
_LOGGER.error("Station %s is not available", station_id)
return
async_add_entities([AmpioSmogQuality(api, station_id, name)], True)
class AmpioSmogQuality(AirQualityEntity):
"""Implementation of an Ampio Smog air quality entity."""
_attr_attribution = "Data provided by Ampio"
def __init__(
self, api: AmpioSmogMapData, station_id: str, name: str | None
) -> None:
"""Initialize the air quality entity."""
self._ampio = api
self._station_id = station_id
self._name = name or api.api.name
@property
@override
def name(self) -> str:
"""Return the name of the air quality entity."""
return self._name
@property
@override
def unique_id(self) -> str:
"""Return unique_name."""
return f"ampio_smog_{self._station_id}" # pylint: disable=home-assistant-entity-unique-id-redundant-domain
@property
@override
def particulate_matter_2_5(self) -> str | None:
"""Return the particulate matter 2.5 level."""
return self._ampio.api.pm2_5 # type: ignore[no-any-return]
@property
@override
def particulate_matter_10(self) -> str | None:
"""Return the particulate matter 10 level."""
return self._ampio.api.pm10 # type: ignore[no-any-return]
async def async_update(self) -> None:
"""Get the latest data from the AmpioMap API."""
await self._ampio.async_update()
class AmpioSmogMapData:
"""Get the latest data and update the states."""
def __init__(self, api: AmpioSmog) -> None:
"""Initialize the data object."""
self.api = api
@Throttle(SCAN_INTERVAL)
async def async_update(self) -> None:
"""Get the latest data from AmpioMap."""
await self.api.get_data()
-7
View File
@@ -1,7 +0,0 @@
"""Constants for Ampio Air Quality platform."""
from datetime import timedelta
from typing import Final
CONF_STATION_ID: Final = "station_id"
SCAN_INTERVAL: Final = timedelta(minutes=10)
@@ -1,10 +0,0 @@
{
"domain": "ampio",
"name": "Ampio Smart Smog System",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/ampio",
"iot_class": "cloud_polling",
"loggers": ["asmog"],
"quality_scale": "legacy",
"requirements": ["asmog==0.0.6"]
}
+7 -7
View File
@@ -1,23 +1,23 @@
{
"entity": {
"sensor": {
"available_volume": {
"default": "mdi:car-coolant-level"
},
"ground_water_level": {
"default": "mdi:arrow-collapse-down"
},
"in_flow": {
"default": "mdi:water-plus-outline"
},
"meter_value": {
"level_from_sensor": {
"default": "mdi:waves-arrow-up"
},
"level_from_top": {
"default": "mdi:waves"
},
"out_volume": {
"default": "mdi:water-pump"
},
"stored_volume": {
"default": "mdi:car-coolant-level"
},
"water_level": {
"default": "mdi:waves"
}
}
}
+6 -6
View File
@@ -46,8 +46,8 @@ class AqvifySensorAggrEntityDescription(SensorEntityDescription):
ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
AqvifySensorEntityDescription(
key="meter_value",
translation_key="meter_value",
key="level_from_sensor",
translation_key="level_from_sensor",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
@@ -55,8 +55,8 @@ ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
value_fn=lambda value: value.meter_value,
),
AqvifySensorEntityDescription(
key="water_level",
translation_key="water_level",
key="level_from_top",
translation_key="level_from_top",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
@@ -64,8 +64,8 @@ ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
value_fn=lambda value: value.water_level,
),
AqvifySensorEntityDescription(
key="stored_volume",
translation_key="stored_volume",
key="available_volume",
translation_key="available_volume",
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_STORAGE,
+8 -8
View File
@@ -34,23 +34,23 @@
},
"entity": {
"sensor": {
"available_volume": {
"name": "Available volume"
},
"ground_water_level": {
"name": "Ground water level"
},
"in_flow": {
"name": "Inflow"
},
"meter_value": {
"name": "Meter value"
"level_from_sensor": {
"name": "Level from sensor"
},
"level_from_top": {
"name": "Level from top"
},
"out_volume": {
"name": "Outflow"
},
"stored_volume": {
"name": "Stored volume"
},
"water_level": {
"name": "Water level"
}
}
},
@@ -12,7 +12,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.const import (
from homeassistant.const import ( # noqa: F401
ATTR_AREA_ID,
ATTR_ENTITY_ID,
ATTR_FLOOR_ID,
@@ -58,7 +58,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.script import (
from homeassistant.helpers.script import ( # noqa: F401
ATTR_CUR,
ATTR_MAX,
CONF_MAX,
@@ -91,6 +91,8 @@ from .const import (
DEFAULT_INITIAL_STATE,
DOMAIN,
LOGGER,
AutomationEntityCapabilityAttribute,
AutomationEntityStateAttribute,
)
from .helpers import async_get_blueprints
from .trace import trace_automation
@@ -318,7 +320,13 @@ class BaseAutomationEntity(ToggleEntity, ABC):
"""Base class for automation entities."""
_entity_component_unrecorded_attributes = frozenset(
(ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, CONF_ID)
(
AutomationEntityStateAttribute.LAST_TRIGGERED,
AutomationEntityStateAttribute.MODE,
AutomationEntityStateAttribute.CUR,
AutomationEntityStateAttribute.MAX,
AutomationEntityCapabilityAttribute.ID,
)
)
raw_config: ConfigType | None
@@ -327,7 +335,7 @@ class BaseAutomationEntity(ToggleEntity, ABC):
def capability_attributes(self) -> dict[str, Any] | None:
"""Return capability attributes."""
if self.unique_id is not None:
return {CONF_ID: self.unique_id}
return {AutomationEntityCapabilityAttribute.ID: self.unique_id}
return None
@cached_property
@@ -507,13 +515,15 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
@override
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the entity state attributes."""
attrs = {
ATTR_LAST_TRIGGERED: self.action_script.last_triggered,
ATTR_MODE: self.action_script.script_mode,
ATTR_CUR: self.action_script.runs,
attrs: dict[str, Any] = {
AutomationEntityStateAttribute.LAST_TRIGGERED: (
self.action_script.last_triggered
),
AutomationEntityStateAttribute.MODE: self.action_script.script_mode,
AutomationEntityStateAttribute.CUR: self.action_script.runs,
}
if self.action_script.supports_max:
attrs[ATTR_MAX] = self.action_script.max_runs
attrs[AutomationEntityStateAttribute.MAX] = self.action_script.max_runs
return attrs
@property
@@ -1,10 +1,27 @@
"""Constants for the automation integration."""
from enum import StrEnum
import logging
CONF_TRIGGER_VARIABLES = "trigger_variables"
DOMAIN = "automation"
class AutomationEntityCapabilityAttribute(StrEnum):
"""Capability attributes for automation entities."""
ID = "id"
class AutomationEntityStateAttribute(StrEnum):
"""State attributes for automation entities."""
LAST_TRIGGERED = "last_triggered"
MODE = "mode"
CUR = "current"
MAX = "max"
CONF_HIDE_ENTITY = "hide_entity"
CONF_CONDITION_TYPE = "condition_type"
@@ -38,7 +38,7 @@
"data_description": {
"host": "The URL of your Autoskope API endpoint. Only change this if you use a white-label portal."
},
"name": "Advanced settings"
"name": "Additional settings"
}
},
"title": "Connect to Autoskope"
@@ -1 +0,0 @@
"""The avion component."""
-123
View File
@@ -1,123 +0,0 @@
"""Support for Avion dimmers."""
import importlib
import time
from typing import Any, override
import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode,
LightEntity,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_DEVICES,
CONF_ID,
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
DEVICE_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): cv.string,
vol.Optional(CONF_ID): cv.positive_int,
vol.Optional(CONF_NAME): cv.string,
}
)
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA},
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up an Avion switch."""
avion = importlib.import_module("avion")
lights = [
AvionLight(
avion.Avion(
mac=address,
passphrase=device_config[CONF_API_KEY],
name=device_config.get(CONF_NAME),
object_id=device_config.get(CONF_ID),
connect=False,
)
)
for address, device_config in config[CONF_DEVICES].items()
]
if CONF_USERNAME in config and CONF_PASSWORD in config:
lights.extend(
AvionLight(device)
for device in avion.get_devices(
config[CONF_USERNAME], config[CONF_PASSWORD]
)
)
add_entities(lights)
class AvionLight(LightEntity):
"""Representation of an Avion light."""
_attr_support_color_mode = ColorMode.BRIGHTNESS
_attr_support_color_modes = {ColorMode.BRIGHTNESS}
_attr_should_poll = False
_attr_assumed_state = True
_attr_is_on = True
def __init__(self, device):
"""Initialize the light."""
self._attr_name = device.name
self._attr_unique_id = device.mac
self._attr_brightness = 255
self._switch = device
def set_state(self, brightness):
"""Set the state of this lamp to the provided brightness."""
avion = importlib.import_module("avion")
# Bluetooth LE is unreliable, and the connection may drop at any
# time. Make an effort to re-establish the link.
initial = time.monotonic()
while True:
if time.monotonic() - initial >= 10:
return False
try:
self._switch.set_brightness(brightness)
break
except avion.AvionException:
self._switch.connect()
return True
@override
def turn_on(self, **kwargs: Any) -> None:
"""Turn the specified or all lights on."""
if (brightness := kwargs.get(ATTR_BRIGHTNESS)) is not None:
self._attr_brightness = brightness
self.set_state(self.brightness)
self._attr_is_on = True
@override
def turn_off(self, **kwargs: Any) -> None:
"""Turn the specified or all lights off."""
self.set_state(0)
self._attr_is_on = False
@@ -1,9 +0,0 @@
{
"domain": "avion",
"name": "Avi-on",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/avion",
"iot_class": "assumed_state",
"quality_scale": "legacy",
"requirements": ["avion==0.10"]
}
+24 -20
View File
@@ -27,6 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import TextSelector, TextSelectorConfig
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.ssdp import (
ATTR_UPNP_FRIENDLY_NAME,
@@ -98,8 +99,11 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
else:
if (serial := self._get_serial_number(api)) is None:
return self.async_abort(reason="no_serial_number")
if not self.unique_id:
if (serial := self._get_formatted_serial(api)) is None:
return self.async_abort(reason="no_serial_number")
await self.async_set_unique_id(serial)
config = {
CONF_PROTOCOL: user_input[CONF_PROTOCOL],
CONF_HOST: user_input[CONF_HOST],
@@ -108,8 +112,6 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PASSWORD: user_input[CONF_PASSWORD],
}
await self.async_set_unique_id(format_mac(serial))
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
return self.async_update_and_abort(
@@ -124,7 +126,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
self.config = config | {CONF_MODEL: api.vapix.product_number}
return await self._create_entry(serial)
return await self._create_entry()
data = self.discovery_schema or {
vol.Required(CONF_PROTOCOL): vol.In(PROTOCOL_CHOICES),
@@ -141,7 +143,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def _create_entry(self, serial: str) -> ConfigFlowResult:
async def _create_entry(self) -> ConfigFlowResult:
"""Create entry for device.
Use the discovered device name when available.
@@ -149,7 +151,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
if (title_placeholders := self.context.get("title_placeholders")) is not None:
name = title_placeholders[CONF_NAME]
else:
name = f"{self.config[CONF_MODEL]} - {serial}"
name = f"{self.config[CONF_MODEL]} - {self.unique_id}"
self.config[CONF_NAME] = name
return self.async_create_entry(title=name, data=self.config)
@@ -196,7 +198,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
return await self._process_discovered_device(
{
CONF_HOST: discovery_info.ip,
CONF_MAC: format_mac(discovery_info.macaddress),
CONF_MAC: discovery_info.macaddress,
CONF_NAME: discovery_info.hostname,
CONF_PORT: 80,
}
@@ -211,7 +213,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
return await self._process_discovered_device(
{
CONF_HOST: url.hostname,
CONF_MAC: format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]),
CONF_MAC: discovery_info.upnp[ATTR_UPNP_SERIAL],
CONF_NAME: f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]}",
CONF_PORT: url.port,
}
@@ -225,7 +227,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
return await self._process_discovered_device(
{
CONF_HOST: discovery_info.host,
CONF_MAC: format_mac(discovery_info.properties["macaddress"]),
CONF_MAC: discovery_info.properties["macaddress"],
CONF_NAME: discovery_info.name.split(".", 1)[0],
CONF_PORT: discovery_info.port,
}
@@ -235,17 +237,17 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
self, discovery_info: dict[str, Any]
) -> ConfigFlowResult:
"""Prepare configuration for a discovered Axis device."""
if discovery_info[CONF_MAC][:8] not in AXIS_OUI:
serial = format_mac(discovery_info[CONF_MAC])
if serial[:8] not in AXIS_OUI:
return self.async_abort(reason="not_axis_device")
if is_link_local(ip_address(discovery_info[CONF_HOST])):
return self.async_abort(reason="link_local_address")
await self.async_set_unique_id(discovery_info[CONF_MAC])
self._abort_if_unique_id_configured(
updates={CONF_HOST: discovery_info[CONF_HOST]}, reload_on_update=False
)
if await self.async_set_unique_id(serial):
self._abort_if_unique_id_configured(
updates={CONF_HOST: discovery_info[CONF_HOST]}, reload_on_update=False
)
self.context.update(
{
@@ -259,7 +261,9 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
self.discovery_schema = {
vol.Required(CONF_PROTOCOL): vol.In(PROTOCOL_CHOICES),
vol.Required(CONF_HOST, default=discovery_info[CONF_HOST]): str,
vol.Required(CONF_HOST, default=discovery_info[CONF_HOST]): TextSelector(
TextSelectorConfig(read_only=True)
),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
@@ -268,16 +272,16 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
@staticmethod
def _get_serial_number(api: axis.AxisDevice) -> str | None:
def _get_formatted_serial(api: axis.AxisDevice) -> str | None:
"""Retrieve the device serial number from the Axis API.
Tries basic_device_info first, then property_handler. Returns None if not found.
"""
vapix = api.vapix
if vapix.basic_device_info.initialized:
return vapix.basic_device_info["0"].serial_number
return format_mac(vapix.basic_device_info["0"].serial_number)
if vapix.params.property_handler.initialized:
return vapix.params.property_handler["0"].system_serial_number
return format_mac(vapix.params.property_handler["0"].system_serial_number)
return None
@@ -1 +0,0 @@
"""The beewi_smartclim component."""
@@ -1,10 +0,0 @@
{
"domain": "beewi_smartclim",
"name": "BeeWi SmartClim BLE sensor",
"codeowners": ["@alemuro"],
"documentation": "https://www.home-assistant.io/integrations/beewi_smartclim",
"iot_class": "local_polling",
"loggers": ["beewi_smartclim"],
"quality_scale": "legacy",
"requirements": ["beewi-smartclim==0.0.10"]
}
@@ -1,84 +0,0 @@
"""Platform for beewi_smartclim integration."""
from beewi_smartclim import BeewiSmartClimPoller
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
)
from homeassistant.const import CONF_MAC, CONF_NAME, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
# Default values
DEFAULT_NAME = "BeeWi SmartClim"
# Sensor config
SENSOR_TYPES = [
[SensorDeviceClass.TEMPERATURE, "Temperature", UnitOfTemperature.CELSIUS],
[SensorDeviceClass.HUMIDITY, "Humidity", PERCENTAGE],
[SensorDeviceClass.BATTERY, "Battery", PERCENTAGE],
]
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_MAC): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the beewi_smartclim platform."""
mac = config[CONF_MAC]
prefix = config[CONF_NAME]
poller = BeewiSmartClimPoller(mac)
sensors = []
for sensor_type in SENSOR_TYPES:
device = sensor_type[0]
name = sensor_type[1]
unit = sensor_type[2]
# `prefix` is the name configured by the user for the sensor, we're appending
# the device type at the end of the name (garden -> garden temperature)
if prefix:
name = f"{prefix} {name}"
sensors.append(BeewiSmartclimSensor(poller, name, mac, device, unit))
add_entities(sensors)
class BeewiSmartclimSensor(SensorEntity):
"""Representation of a Sensor."""
def __init__(self, poller, name, mac, device, unit):
"""Initialize the sensor."""
self._poller = poller
self._attr_name = name
self._device = device
self._attr_native_unit_of_measurement = unit
self._attr_device_class = self._device
self._attr_unique_id = f"{mac}_{device}"
def update(self) -> None:
"""Fetch new state data from the poller."""
self._poller.update_sensor()
self._attr_native_value = None
if self._device == SensorDeviceClass.TEMPERATURE:
self._attr_native_value = self._poller.get_temperature()
if self._device == SensorDeviceClass.HUMIDITY:
self._attr_native_value = self._poller.get_humidity()
if self._device == SensorDeviceClass.BATTERY:
self._attr_native_value = self._poller.get_battery()
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.22",
"habluetooth==6.19.1"
"habluetooth==6.24.0"
]
}
@@ -2,13 +2,13 @@
"domain": "bosch_shc",
"name": "Bosch SHC",
"after_dependencies": ["zeroconf"],
"codeowners": ["@tschamm"],
"codeowners": ["@tschamm", "@mosandlt"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bosch_shc",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["boschshcpy"],
"requirements": ["boschshcpy==0.2.111"],
"requirements": ["boschshcpy==0.3.5"],
"zeroconf": [
{
"name": "bosch shc*",
+14 -7
View File
@@ -68,6 +68,7 @@ from .const import (
EVENT_UID,
LIST_EVENT_FIELDS,
CalendarEntityFeature,
CalendarEntityStateAttribute,
)
# mypy: disallow-any-generics
@@ -519,7 +520,9 @@ class CalendarEntity(Entity):
entity_description: CalendarEntityDescription
_entity_component_unrecorded_attributes = frozenset({"description"})
_entity_component_unrecorded_attributes = frozenset(
{CalendarEntityStateAttribute.DESCRIPTION}
)
_alarm_unsubs: list[CALLBACK_TYPE] | None = None
_event_listeners: (
@@ -573,12 +576,16 @@ class CalendarEntity(Entity):
return None
return {
"message": event.summary,
"all_day": event.all_day,
"start_time": event.start_datetime_local.strftime(DATE_STR_FORMAT),
"end_time": event.end_datetime_local.strftime(DATE_STR_FORMAT),
"location": event.location or "",
"description": event.description or "",
CalendarEntityStateAttribute.MESSAGE: event.summary,
CalendarEntityStateAttribute.ALL_DAY: event.all_day,
CalendarEntityStateAttribute.START_TIME: event.start_datetime_local.strftime(
DATE_STR_FORMAT
),
CalendarEntityStateAttribute.END_TIME: event.end_datetime_local.strftime(
DATE_STR_FORMAT
),
CalendarEntityStateAttribute.LOCATION: event.location or "",
CalendarEntityStateAttribute.DESCRIPTION: event.description or "",
}
@final
+12 -1
View File
@@ -1,6 +1,6 @@
"""Constants for calendar components."""
from enum import IntFlag
from enum import IntFlag, StrEnum
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
@@ -14,6 +14,17 @@ DOMAIN = "calendar"
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
class CalendarEntityStateAttribute(StrEnum):
"""State attributes for calendar entities."""
MESSAGE = "message"
ALL_DAY = "all_day"
START_TIME = "start_time"
END_TIME = "end_time"
LOCATION = "location"
DESCRIPTION = "description"
class CalendarEntityFeature(IntFlag):
"""Supported features of the calendar entity."""
+4 -3
View File
@@ -41,6 +41,7 @@ from .const import (
INTENT_OPEN_COVER,
CoverDeviceClass,
CoverEntityFeature,
CoverEntityStateAttribute,
CoverState,
)
from .trigger import make_cover_closed_trigger, make_cover_opened_trigger
@@ -260,13 +261,13 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the state attributes."""
data: dict[str, Any] = {}
data[ATTR_IS_CLOSED] = self.is_closed
data[CoverEntityStateAttribute.IS_CLOSED] = self.is_closed
if (current := self.current_cover_position) is not None:
data[ATTR_CURRENT_POSITION] = current
data[CoverEntityStateAttribute.CURRENT_POSITION] = current
if (current_tilt := self.current_cover_tilt_position) is not None:
data[ATTR_CURRENT_TILT_POSITION] = current_tilt
data[CoverEntityStateAttribute.CURRENT_TILT_POSITION] = current_tilt
return data
+9
View File
@@ -10,6 +10,15 @@ ATTR_IS_CLOSED = "is_closed"
ATTR_POSITION = "position"
ATTR_TILT_POSITION = "tilt_position"
class CoverEntityStateAttribute(StrEnum):
"""State attributes for cover entities."""
IS_CLOSED = "is_closed"
CURRENT_POSITION = "current_position"
CURRENT_TILT_POSITION = "current_tilt_position"
INTENT_OPEN_COVER = "HassOpenCover"
INTENT_CLOSE_COVER = "HassCloseCover"
@@ -37,6 +37,35 @@ class TrackingType(StrEnum):
POSITION = "position"
class DeviceTrackerEntityCapabilityAttribute(StrEnum):
"""Capability attributes for device tracker entities."""
TRACKING_TYPE = "tracking_type"
class DeviceTrackerEntityStateAttribute(StrEnum):
"""State attributes common to device tracker entities."""
SOURCE_TYPE = "source_type"
IN_ZONES = "in_zones"
class TrackerEntityStateAttribute(StrEnum):
"""State attributes set by TrackerEntity."""
LATITUDE = "latitude"
LONGITUDE = "longitude"
GPS_ACCURACY = "gps_accuracy"
class ScannerEntityStateAttribute(StrEnum):
"""State attributes set by ScannerEntity."""
IP = "ip"
MAC = "mac"
HOST_NAME = "host_name"
CONF_SCAN_INTERVAL: Final = "interval_seconds"
SCAN_INTERVAL: Final = timedelta(seconds=12)
@@ -8,7 +8,7 @@ from propcache.api import cached_property
from homeassistant.components import zone
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
from homeassistant.const import (
from homeassistant.const import ( # noqa: F401
ATTR_BATTERY_LEVEL,
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
@@ -42,7 +42,7 @@ from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.loader import async_suggest_report_issue
from homeassistant.util.hass_dict import HassKey
from .const import (
from .const import ( # noqa: F401
ATTR_HOST_NAME,
ATTR_IN_ZONES,
ATTR_IP,
@@ -53,7 +53,11 @@ from .const import (
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
DeviceTrackerEntityCapabilityAttribute,
DeviceTrackerEntityStateAttribute,
ScannerEntityStateAttribute,
SourceType,
TrackerEntityStateAttribute,
TrackingType,
)
@@ -215,7 +219,9 @@ class BaseTrackerEntity(Entity):
@override
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_SOURCE_TYPE: self.source_type}
attr: dict[str, Any] = {
DeviceTrackerEntityStateAttribute.SOURCE_TYPE: self.source_type
}
if self.battery_level is not None:
attr[ATTR_BATTERY_LEVEL] = self.battery_level
@@ -243,7 +249,7 @@ class TrackerEntity(
entity_description: TrackerEntityDescription
_attr_capability_attributes: dict[str, Any] = {
ATTR_TRACKING_TYPE: TrackingType.POSITION
DeviceTrackerEntityCapabilityAttribute.TRACKING_TYPE: TrackingType.POSITION
}
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
@@ -406,13 +412,15 @@ class TrackerEntity(
@override
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
attr: dict[str, Any] = {
DeviceTrackerEntityStateAttribute.IN_ZONES: self.__in_zones or []
}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
attr[TrackerEntityStateAttribute.LATITUDE] = self.latitude
attr[TrackerEntityStateAttribute.LONGITUDE] = self.longitude
attr[TrackerEntityStateAttribute.GPS_ACCURACY] = self.location_accuracy
return attr
@@ -425,7 +433,7 @@ class BaseScannerEntity(BaseTrackerEntity):
"""
_attr_capability_attributes: dict[str, Any] = {
ATTR_TRACKING_TYPE: TrackingType.CONNECTION
DeviceTrackerEntityCapabilityAttribute.TRACKING_TYPE: TrackingType.CONNECTION
}
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
@@ -556,7 +564,7 @@ class BaseScannerEntity(BaseTrackerEntity):
@override
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr: dict[str, Any] = {DeviceTrackerEntityStateAttribute.IN_ZONES: []}
attr.update(super().state_attributes)
if not self.is_connected:
@@ -571,7 +579,7 @@ class BaseScannerEntity(BaseTrackerEntity):
):
return attr
attr[ATTR_IN_ZONES] = [
attr[DeviceTrackerEntityStateAttribute.IN_ZONES] = [
associated_zone,
*zone.async_get_enclosing_zones(self.hass, associated_zone),
]
@@ -721,10 +729,10 @@ class ScannerEntity(
attr = super().state_attributes
if ip_address := self.ip_address:
attr[ATTR_IP] = ip_address
attr[ScannerEntityStateAttribute.IP] = ip_address
if (mac_address := self.mac_address) is not None:
attr[ATTR_MAC] = mac_address
attr[ScannerEntityStateAttribute.MAC] = mac_address
if (hostname := self.hostname) is not None:
attr[ATTR_HOST_NAME] = hostname
attr[ScannerEntityStateAttribute.HOST_NAME] = hostname
return attr
+1 -1
View File
@@ -29,7 +29,7 @@
"resolver_ipv6": "Resolver used for the IPv6 lookup."
},
"description": "Optionally change resolvers and ports.",
"name": "Advanced options"
"name": "Additional options"
}
}
}
+14 -1
View File
@@ -17,7 +17,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from .auth import DropboxConfigEntryAuth
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH2_SCOPES
type DropboxConfigEntry = ConfigEntry[DropboxAPIClient]
@@ -31,6 +31,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> b
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
token = entry.data["token"]
if not set(token.get("scope", "").split()).issuperset(OAUTH2_SCOPES):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="missing_scopes",
)
if "refresh_token" not in token:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="missing_refresh_token",
)
oauth2_session = OAuth2Session(hass, entry, oauth2_implementation)
auth = DropboxConfigEntryAuth(
@@ -1,7 +1,5 @@
"""Application credentials platform for the Dropbox integration."""
from typing import override
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
@@ -9,14 +7,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
LocalOAuth2ImplementationWithPkce,
)
from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> AbstractOAuth2Implementation:
"""Return custom auth implementation."""
return DropboxOAuth2Implementation(
"""Return auth implementation."""
return LocalOAuth2ImplementationWithPkce(
hass,
auth_domain,
credential.client_id,
@@ -24,21 +22,3 @@ async def async_get_auth_implementation(
OAUTH2_TOKEN,
credential.client_secret,
)
class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Custom Dropbox OAuth2 implementation.
Adds the necessary authorize url parameters.
"""
@property
@override
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
data: dict = {
"token_access_type": "offline",
"scope": " ".join(OAUTH2_SCOPES),
}
data.update(super().extra_authorize_data)
return data
@@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .auth import DropboxConfigFlowAuth
from .const import DOMAIN
from .const import DOMAIN, OAUTH2_SCOPES
class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
@@ -26,6 +26,15 @@ class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Return logger."""
return logging.getLogger(__name__)
@property
@override
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return {
"token_access_type": "offline",
"scope": " ".join(OAUTH2_SCOPES),
}
@override
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow, or update existing entry."""
@@ -51,6 +60,9 @@ class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
token = entry_data[CONF_TOKEN]
if not set(token.get("scope", "").split()).issuperset(OAUTH2_SCOPES):
return await self.async_step_reauth_permissions()
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
@@ -60,3 +72,11 @@ class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_step_reauth_permissions(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that additional permissions are required."""
if user_input is None:
return self.async_show_form(step_id="reauth_permissions")
return await self.async_step_user()
@@ -12,6 +12,7 @@ OAUTH2_SCOPES = [
"account_info.read",
"files.content.read",
"files.content.write",
"files.metadata.read",
]
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
@@ -24,10 +24,20 @@
"reauth_confirm": {
"description": "The Dropbox integration needs to re-authenticate your account.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"reauth_permissions": {
"description": "The Dropbox integration requires additional permissions to function correctly.",
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"exceptions": {
"missing_refresh_token": {
"message": "[%key:component::dropbox::config::step::reauth_confirm::description%]"
},
"missing_scopes": {
"message": "[%key:component::dropbox::config::step::reauth_permissions::description%]"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
+20 -10
View File
@@ -641,25 +641,34 @@ class Thermostat(ClimateEntity):
for device in device_registry.devices.values()
for sensor_info in sensors_info
if device.name == sensor_info["name"]
and any(identifier[0] == DOMAIN for identifier in device.identifiers)
]
def _active_climate_name(self) -> str:
"""Return the ecobee climate name of the active comfort setting.
``preset_mode`` is the climate *name*, but ``_preset_modes`` is keyed by
climateRef, so the built-in presets are translated back to their ecobee
name. Holds that are not a comfort setting (temperature/vacation/
indefinite away) are not real climates; per ecobee they follow the Home
comfort setting's sensor participation, so fall back to "Home".
"""
# https://support.ecobee.com/s/articles/SmartSensors-Sensor-Participation
preset_mode = self.preset_mode
if preset_mode is None:
return "Home"
mode = HASS_TO_ECOBEE_PRESET.get(preset_mode, preset_mode)
return mode if mode in self._preset_modes.values() else "Home"
@property
def active_sensors_in_preset_mode(self) -> list:
"""Return the currently active/participating sensors."""
# https://support.ecobee.com/s/articles/SmartSensors-Sensor-Participation
# During a manual hold, the ecobee will follow the Sensor Participation
# rules for the Home Comfort Settings
mode = self._preset_modes.get(self.preset_mode, "Home")
return self._sensors_in_preset_mode(mode)
return self._sensors_in_preset_mode(self._active_climate_name())
@property
def active_sensor_devices_in_preset_mode(self) -> list:
"""Return the currently active/participating sensor devices."""
# https://support.ecobee.com/s/articles/SmartSensors-Sensor-Participation
# During a manual hold, the ecobee will follow the Sensor Participation
# rules for the Home Comfort Settings
mode = self._preset_modes.get(self.preset_mode, "Home")
return self._sensor_devices_in_preset_mode(mode)
return self._sensor_devices_in_preset_mode(self._active_climate_name())
@override
def set_preset_mode(self, preset_mode: str) -> None:
@@ -950,6 +959,7 @@ class Thermostat(ClimateEntity):
for device in device_registry.devices.values()
for sensor_name in sensor_names
if device.name == sensor_name
and any(identifier[0] == DOMAIN for identifier in device.identifiers)
]
)
@@ -31,14 +31,14 @@
"step": {
"init": {
"data": {
"configure_voice": "Configure advanced voice settings",
"configure_voice": "Configure voice settings",
"model": "Model",
"stt_auto_language": "Auto-detect language",
"stt_model": "Speech-to-text model",
"voice": "Voice"
},
"data_description": {
"configure_voice": "Configure advanced voice settings. Find more information in the ElevenLabs documentation.",
"configure_voice": "Configure voice settings. Find more information in the ElevenLabs documentation.",
"model": "ElevenLabs model to use. Please note that not all models support all languages equally well.",
"stt_auto_language": "Automatically detect the spoken language for speech-to-text.",
"stt_model": "Speech-to-text model to use.",
+15 -4
View File
@@ -18,7 +18,14 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN, DoorbellEventType
from .const import (
ATTR_EVENT_TYPE,
ATTR_EVENT_TYPES,
DOMAIN,
DoorbellEventType,
EventEntityCapabilityAttribute,
EventEntityStateAttribute,
)
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN)
@@ -110,7 +117,9 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Representation of an Event entity."""
_entity_component_unrecorded_attributes = frozenset({ATTR_EVENT_TYPES})
_entity_component_unrecorded_attributes = frozenset(
{EventEntityCapabilityAttribute.EVENT_TYPES}
)
entity_description: EventEntityDescription
_attr_device_class: EventDeviceClass | None
@@ -168,7 +177,7 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
def capability_attributes(self) -> dict[str, list[str]]:
"""Return capability attributes."""
return {
ATTR_EVENT_TYPES: self.event_types,
EventEntityCapabilityAttribute.EVENT_TYPES: self.event_types,
}
@property
@@ -185,7 +194,9 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
@override
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
attributes = {ATTR_EVENT_TYPE: self.__last_event_type}
attributes: dict[str, Any] = {
EventEntityStateAttribute.EVENT_TYPE: self.__last_event_type
}
if last_event_attributes := self.__last_event_attributes:
attributes |= last_event_attributes
return attributes
+12
View File
@@ -7,6 +7,18 @@ ATTR_EVENT_TYPE = "event_type"
ATTR_EVENT_TYPES = "event_types"
class EventEntityCapabilityAttribute(StrEnum):
"""Capability attributes for event entities."""
EVENT_TYPES = "event_types"
class EventEntityStateAttribute(StrEnum):
"""State attributes for event entities."""
EVENT_TYPE = "event_type"
class DoorbellEventType(StrEnum):
"""Standard event types for doorbell device class."""
+12 -8
View File
@@ -29,6 +29,8 @@ from homeassistant.util.percentage import (
ranged_value_to_percentage,
)
from .const import FanEntityCapabilityAttribute, FanEntityStateAttribute
_LOGGER = logging.getLogger(__name__)
DOMAIN = "fan"
@@ -202,7 +204,9 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Base class for fan entities."""
_entity_component_unrecorded_attributes = frozenset({ATTR_PRESET_MODES})
_entity_component_unrecorded_attributes = frozenset(
{FanEntityCapabilityAttribute.PRESET_MODES}
)
entity_description: FanEntityDescription
_attr_current_direction: str | None = None
@@ -372,14 +376,14 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@override
def capability_attributes(self) -> dict[str, list[str] | None]:
"""Return capability attributes."""
attrs = {}
attrs: dict[str, list[str] | None] = {}
supported_features = self.supported_features
if (
FanEntityFeature.SET_SPEED in supported_features
or FanEntityFeature.PRESET_MODE in supported_features
):
attrs[ATTR_PRESET_MODES] = self.preset_modes
attrs[FanEntityCapabilityAttribute.PRESET_MODES] = self.preset_modes
return attrs
@@ -392,19 +396,19 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
supported_features = self.supported_features
if FanEntityFeature.DIRECTION in supported_features:
data[ATTR_DIRECTION] = self.current_direction
data[FanEntityStateAttribute.DIRECTION] = self.current_direction
if FanEntityFeature.OSCILLATE in supported_features:
data[ATTR_OSCILLATING] = self.oscillating
data[FanEntityStateAttribute.OSCILLATING] = self.oscillating
has_set_speed = FanEntityFeature.SET_SPEED in supported_features
if has_set_speed:
data[ATTR_PERCENTAGE] = self.percentage
data[ATTR_PERCENTAGE_STEP] = self.percentage_step
data[FanEntityStateAttribute.PERCENTAGE] = self.percentage
data[FanEntityStateAttribute.PERCENTAGE_STEP] = self.percentage_step
if has_set_speed or FanEntityFeature.PRESET_MODE in supported_features:
data[ATTR_PRESET_MODE] = self.preset_mode
data[FanEntityStateAttribute.PRESET_MODE] = self.preset_mode
return data
+19
View File
@@ -0,0 +1,19 @@
"""Constants for the fan component."""
from enum import StrEnum
class FanEntityCapabilityAttribute(StrEnum):
"""Capability attributes for fan entities."""
PRESET_MODES = "preset_modes"
class FanEntityStateAttribute(StrEnum):
"""State attributes for fan entities."""
DIRECTION = "direction"
OSCILLATING = "oscillating"
PERCENTAGE = "percentage"
PERCENTAGE_STEP = "percentage_step"
PRESET_MODE = "preset_mode"
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.7"]
"requirements": ["home-assistant-frontend==20260624.0"]
}
@@ -7,7 +7,7 @@ from typing import Any, final, override
from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE # noqa: F401
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -15,6 +15,8 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import GeolocationEntityStateAttribute
_LOGGER = logging.getLogger(__name__)
DOMAIN = "geo_location"
@@ -101,9 +103,9 @@ class GeolocationEvent(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@override
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of this external event."""
data: dict[str, Any] = {ATTR_SOURCE: self.source}
data: dict[str, Any] = {GeolocationEntityStateAttribute.SOURCE: self.source}
if self.latitude is not None:
data[ATTR_LATITUDE] = round(self.latitude, 5)
data[GeolocationEntityStateAttribute.LATITUDE] = round(self.latitude, 5)
if self.longitude is not None:
data[ATTR_LONGITUDE] = round(self.longitude, 5)
data[GeolocationEntityStateAttribute.LONGITUDE] = round(self.longitude, 5)
return data
@@ -0,0 +1,11 @@
"""Constants for the geo_location component."""
from enum import StrEnum
class GeolocationEntityStateAttribute(StrEnum):
"""State attributes for geolocation entities."""
SOURCE = "source"
LATITUDE = "latitude"
LONGITUDE = "longitude"
@@ -33,7 +33,7 @@
"data_description": {
"min_state_duration": "The minimum state duration to account for the statistics. Default is 0 seconds."
},
"name": "Advanced settings"
"name": "Additional settings"
}
}
},
@@ -127,7 +127,7 @@
"data": {
"channel": "Channel"
},
"description": "Start a channel change for your Zigbee and Thread networks.\n\nNote: this is an advanced operation and can leave your Thread and Zigbee networks inoperable if the new channel is congested. Depending on existing network conditions, many of your devices may not migrate to the new channel and will require re-joining before they start working again. Use with caution.\n\nOnce you have selected **Submit**, the channel change starts quietly in the background and will finish after a few minutes.",
"description": "Start a channel change for your Zigbee and Thread networks.\n\nNote: this operation can leave your Thread and Zigbee networks inoperable if the new channel is congested. Depending on existing network conditions, many of your devices may not migrate to the new channel and will require re-joining before they start working again. Use with caution.\n\nOnce you have selected **Submit**, the channel change starts quietly in the background and will finish after a few minutes.",
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
},
"install_addon": {
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.13.1"]
"requirements": ["homematicip==2.13.2"]
}
@@ -245,11 +245,14 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN):
)
assert conn
def _get_info_and_disconnect() -> tuple[dict, dict]:
result = get_device_info(conn)
self._disconnect(conn)
return result
info, wlan_settings = await self.hass.async_add_executor_job(
get_device_info, conn
_get_info_and_disconnect
)
# pylint: disable-next=home-assistant-sequential-executor-jobs
await self.hass.async_add_executor_job(self._disconnect, conn)
user_input.update(
{
@@ -1,7 +1,7 @@
{
"domain": "huawei_lte",
"name": "Huawei LTE",
"codeowners": ["@scop", "@fphammerle"],
"codeowners": ["@fphammerle"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/huawei_lte",
"integration_type": "device",
+22 -12
View File
@@ -47,7 +47,9 @@ from .const import ( # noqa: F401
SERVICE_SET_HUMIDITY,
SERVICE_SET_MODE,
HumidifierAction,
HumidifierEntityCapabilityAttribute,
HumidifierEntityFeature,
HumidifierEntityStateAttribute,
)
_LOGGER = logging.getLogger(__name__)
@@ -147,10 +149,10 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
_entity_component_unrecorded_attributes = frozenset(
{
ATTR_MIN_HUMIDITY,
ATTR_MAX_HUMIDITY,
ATTR_AVAILABLE_MODES,
ATTR_TARGET_HUMIDITY_STEP,
HumidifierEntityCapabilityAttribute.MIN_HUMIDITY,
HumidifierEntityCapabilityAttribute.MAX_HUMIDITY,
HumidifierEntityCapabilityAttribute.AVAILABLE_MODES,
HumidifierEntityCapabilityAttribute.TARGET_HUMIDITY_STEP,
}
)
@@ -171,14 +173,18 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
def capability_attributes(self) -> dict[str, Any]:
"""Return capability attributes."""
data: dict[str, Any] = {
ATTR_MIN_HUMIDITY: self.min_humidity,
ATTR_MAX_HUMIDITY: self.max_humidity,
HumidifierEntityCapabilityAttribute.MIN_HUMIDITY: self.min_humidity,
HumidifierEntityCapabilityAttribute.MAX_HUMIDITY: self.max_humidity,
}
if self.target_humidity_step is not None:
data[ATTR_TARGET_HUMIDITY_STEP] = self.target_humidity_step
data[HumidifierEntityCapabilityAttribute.TARGET_HUMIDITY_STEP] = (
self.target_humidity_step
)
if HumidifierEntityFeature.MODES in self.supported_features:
data[ATTR_AVAILABLE_MODES] = self.available_modes
data[HumidifierEntityCapabilityAttribute.AVAILABLE_MODES] = (
self.available_modes
)
return data
@@ -200,16 +206,20 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
data: dict[str, Any] = {}
if self.action is not None:
data[ATTR_ACTION] = self.action if self.is_on else HumidifierAction.OFF
data[HumidifierEntityStateAttribute.ACTION] = (
self.action if self.is_on else HumidifierAction.OFF
)
if self.current_humidity is not None:
data[ATTR_CURRENT_HUMIDITY] = self.current_humidity
data[HumidifierEntityStateAttribute.CURRENT_HUMIDITY] = (
self.current_humidity
)
if self.target_humidity is not None:
data[ATTR_HUMIDITY] = self.target_humidity
data[HumidifierEntityStateAttribute.HUMIDITY] = self.target_humidity
if HumidifierEntityFeature.MODES in self.supported_features:
data[ATTR_MODE] = self.mode
data[HumidifierEntityStateAttribute.MODE] = self.mode
return data
@@ -39,6 +39,24 @@ SERVICE_SET_MODE = "set_mode"
SERVICE_SET_HUMIDITY = "set_humidity"
class HumidifierEntityCapabilityAttribute(StrEnum):
"""Capability attributes for humidifier entities."""
MIN_HUMIDITY = "min_humidity"
MAX_HUMIDITY = "max_humidity"
TARGET_HUMIDITY_STEP = "target_humidity_step"
AVAILABLE_MODES = "available_modes"
class HumidifierEntityStateAttribute(StrEnum):
"""State attributes for humidifier entities."""
ACTION = "action"
CURRENT_HUMIDITY = "current_humidity"
HUMIDITY = "humidity"
MODE = "mode"
class HumidifierEntityFeature(IntFlag):
"""Supported features of the humidifier entity."""
+3 -3
View File
@@ -41,7 +41,7 @@ from homeassistant.helpers.typing import (
VolDictType,
)
from .const import DATA_COMPONENT, DOMAIN, IMAGE_TIMEOUT
from .const import DATA_COMPONENT, DOMAIN, IMAGE_TIMEOUT, ImageEntityStateAttribute
_LOGGER = logging.getLogger(__name__)
@@ -193,7 +193,7 @@ class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""The base class for image entities."""
_entity_component_unrecorded_attributes = frozenset(
{"access_token", "entity_picture"}
{ImageEntityStateAttribute.ACCESS_TOKEN, "entity_picture"}
)
# Entity Properties
@@ -305,7 +305,7 @@ class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@override
def state_attributes(self) -> dict[str, str | None]:
"""Return the state attributes."""
return {"access_token": self.access_tokens[-1]}
return {ImageEntityStateAttribute.ACCESS_TOKEN: self.access_tokens[-1]}
@callback
def async_update_token(self) -> None:
+7
View File
@@ -1,5 +1,6 @@
"""Constants for the image integration."""
from enum import StrEnum
from typing import TYPE_CHECKING, Final
from homeassistant.util.hass_dict import HassKey
@@ -10,6 +11,12 @@ if TYPE_CHECKING:
from . import ImageEntity
class ImageEntityStateAttribute(StrEnum):
"""State attributes for image entities."""
ACCESS_TOKEN = "access_token"
DOMAIN: Final = "image"
DATA_COMPONENT: HassKey[EntityComponent[ImageEntity]] = HassKey(DOMAIN)
@@ -24,6 +24,8 @@ from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from .const import ImageProcessingEntityStateAttribute
_LOGGER = logging.getLogger(__name__)
DOMAIN = "image_processing"
@@ -233,7 +235,10 @@ class ImageProcessingFaceEntity(ImageProcessingEntity):
@override
def state_attributes(self) -> dict[str, Any]:
"""Return device specific state attributes."""
return {ATTR_FACES: self.faces, ATTR_TOTAL_FACES: self.total_faces}
return {
ImageProcessingEntityStateAttribute.FACES: self.faces,
ImageProcessingEntityStateAttribute.TOTAL_FACES: self.total_faces,
}
def process_faces(self, faces: list[FaceInformation], total: int) -> None:
"""Send event with detected faces and store data."""
@@ -0,0 +1,10 @@
"""Constants for the image_processing component."""
from enum import StrEnum
class ImageProcessingEntityStateAttribute(StrEnum):
"""State attributes for image processing entities."""
FACES = "faces"
TOTAL_FACES = "total_faces"
@@ -6,7 +6,7 @@ from typing import Any, Self, override
import voluptuous as vol
from homeassistant.const import (
from homeassistant.const import ( # noqa: F401
ATTR_DATE,
ATTR_EDITABLE,
ATTR_TIME,
@@ -24,6 +24,11 @@ from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.util import dt as dt_util
from .const import (
InputDatetimeEntityCapabilityAttribute,
InputDatetimeEntityStateAttribute,
)
_LOGGER = logging.getLogger(__name__)
DOMAIN = "input_datetime"
@@ -216,7 +221,13 @@ class DateTimeStorageCollection(collection.DictStorageCollection):
class InputDatetime(collection.CollectionEntity, RestoreEntity):
"""Representation of a datetime input."""
_unrecorded_attributes = frozenset({ATTR_EDITABLE, CONF_HAS_DATE, CONF_HAS_TIME})
_unrecorded_attributes = frozenset(
{
InputDatetimeEntityStateAttribute.EDITABLE,
InputDatetimeEntityCapabilityAttribute.HAS_DATE,
InputDatetimeEntityCapabilityAttribute.HAS_TIME,
}
)
_attr_should_poll = False
editable: bool
@@ -338,8 +349,8 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
def capability_attributes(self) -> dict[str, Any]:
"""Return the capability attributes."""
return {
CONF_HAS_DATE: self.has_date,
CONF_HAS_TIME: self.has_time,
InputDatetimeEntityCapabilityAttribute.HAS_DATE: self.has_date,
InputDatetimeEntityCapabilityAttribute.HAS_TIME: self.has_time,
}
@property
@@ -347,24 +358,30 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
attrs: dict[str, Any] = {
ATTR_EDITABLE: self.editable,
InputDatetimeEntityStateAttribute.EDITABLE: self.editable,
}
if self._current_datetime is None:
return attrs
if self.has_date and self._current_datetime is not None:
attrs["year"] = self._current_datetime.year
attrs["month"] = self._current_datetime.month
attrs["day"] = self._current_datetime.day
attrs[InputDatetimeEntityStateAttribute.YEAR] = self._current_datetime.year
attrs[InputDatetimeEntityStateAttribute.MONTH] = (
self._current_datetime.month
)
attrs[InputDatetimeEntityStateAttribute.DAY] = self._current_datetime.day
if self.has_time and self._current_datetime is not None:
attrs["hour"] = self._current_datetime.hour
attrs["minute"] = self._current_datetime.minute
attrs["second"] = self._current_datetime.second
attrs[InputDatetimeEntityStateAttribute.HOUR] = self._current_datetime.hour
attrs[InputDatetimeEntityStateAttribute.MINUTE] = (
self._current_datetime.minute
)
attrs[InputDatetimeEntityStateAttribute.SECOND] = (
self._current_datetime.second
)
if not self.has_date:
attrs["timestamp"] = (
attrs[InputDatetimeEntityStateAttribute.TIMESTAMP] = (
self._current_datetime.hour * 3600
+ self._current_datetime.minute * 60
+ self._current_datetime.second
@@ -372,12 +389,16 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
elif not self.has_time:
extended = py_datetime.datetime.combine(
self._current_datetime, py_datetime.time(0, 0)
self._current_datetime,
py_datetime.time(0, 0),
dt_util.get_default_time_zone(),
)
attrs["timestamp"] = extended.timestamp()
attrs[InputDatetimeEntityStateAttribute.TIMESTAMP] = extended.timestamp()
else:
attrs["timestamp"] = self._current_datetime.timestamp()
attrs[InputDatetimeEntityStateAttribute.TIMESTAMP] = (
self._current_datetime.timestamp()
)
return attrs
@@ -0,0 +1,23 @@
"""Constants for the input_datetime component."""
from enum import StrEnum
class InputDatetimeEntityCapabilityAttribute(StrEnum):
"""Capability attributes for input datetime entities."""
HAS_DATE = "has_date"
HAS_TIME = "has_time"
class InputDatetimeEntityStateAttribute(StrEnum):
"""State attributes for input datetime entities."""
EDITABLE = "editable"
YEAR = "year"
MONTH = "month"
DAY = "day"
HOUR = "hour"
MINUTE = "minute"
SECOND = "second"
TIMESTAMP = "timestamp"
@@ -16,5 +16,5 @@
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "platinum",
"requirements": ["pylitterbot==2025.4.0"]
"requirements": ["pylitterbot==2025.5.0"]
}
+7 -4
View File
@@ -11,7 +11,7 @@ from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
from homeassistant.const import ( # noqa: F401
ATTR_CODE,
ATTR_CODE_FORMAT,
SERVICE_LOCK,
@@ -26,7 +26,7 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, StateType
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN, LockState
from .const import DOMAIN, LockEntityStateAttribute, LockState
_LOGGER = logging.getLogger(__name__)
@@ -52,7 +52,10 @@ class LockEntityFeature(IntFlag):
OPEN = 1
PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT}
PROP_TO_ATTR = {
"changed_by": LockEntityStateAttribute.CHANGED_BY,
"code_format": LockEntityStateAttribute.CODE_FORMAT,
}
# mypy: disallow-any-generics
@@ -245,7 +248,7 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@override
def state_attributes(self) -> dict[str, StateType]:
"""Return the state attributes."""
state_attr = {}
state_attr: dict[str, StateType] = {}
for prop, attr in PROP_TO_ATTR.items():
if (value := getattr(self, prop)) is not None:
state_attr[attr] = value
+7
View File
@@ -5,6 +5,13 @@ from enum import StrEnum
DOMAIN = "lock"
class LockEntityStateAttribute(StrEnum):
"""State attributes for lock entities."""
CHANGED_BY = "changed_by"
CODE_FORMAT = "code_format"
class LockState(StrEnum):
"""State of lock entities."""
+70 -4
View File
@@ -4,6 +4,7 @@ import asyncio
from functools import cache
from typing import TYPE_CHECKING
from aiohasupervisor.models import InterfaceMethod
from matter_server.client import MatterClient
from matter_server.client.exceptions import (
CannotConnect,
@@ -15,13 +16,20 @@ from matter_server.client.exceptions import (
from matter_server.common.errors import MatterError, NodeNotExists
from yarl import URL
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.components.hassio import (
AddonError,
AddonManager,
AddonState,
SupervisorError,
get_supervisor_client,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
@@ -123,6 +131,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MatterConfigEntry) -> bo
async_delete_issue(hass, DOMAIN, "server_version_version_too_old")
async_delete_issue(hass, DOMAIN, "server_version_version_too_new")
await _async_check_ipv6_enabled(hass)
ble_proxy: MatterBleProxy | None = None
async def on_hass_stop(event: Event) -> None:
@@ -252,6 +262,53 @@ def _derive_ble_proxy_url(matter_ws_url: str) -> str | None:
return str(parsed.with_path(new_path))
async def _async_check_ipv6_enabled(hass: HomeAssistant) -> None:
"""Raise a repair issue when IPv6 is disabled in Supervisor network settings.
Matter relies on IPv6 to communicate with devices. On Supervised/HAOS
installations the host network IPv6 method can be disabled per interface,
which silently breaks Matter, so we surface a repair pointing the user at
the network settings.
"""
if not is_hassio(hass):
return
client = get_supervisor_client(hass)
try:
network_info = await client.network.info()
except SupervisorError as err:
LOGGER.debug("Failed to fetch Supervisor network info: %s", err)
return
connected_interfaces = [
interface
for interface in network_info.interfaces
if interface.enabled and interface.connected
]
# Without a connected interface we can't tell whether IPv6 is disabled or
# the network is simply not up yet, so avoid raising a false repair.
if not connected_interfaces:
return
if any(
interface.ipv6 is not None
and interface.ipv6.method is not InterfaceMethod.DISABLED
for interface in connected_interfaces
):
async_delete_issue(hass, DOMAIN, "ipv6_disabled")
return
async_create_issue(
hass,
DOMAIN,
"ipv6_disabled",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="ipv6_disabled",
learn_more_url="homeassistant://config/network",
)
async def _client_listen(
hass: HomeAssistant,
entry: MatterConfigEntry,
@@ -389,11 +446,17 @@ async def _async_ensure_addon_running(
addon_info.options,
catch_error=True,
)
raise ConfigEntryNotReady
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="addon_not_installed",
)
if addon_state is AddonState.NOT_RUNNING:
addon_manager.async_schedule_start_addon(catch_error=True)
raise ConfigEntryNotReady
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="addon_not_running",
)
@callback
@@ -404,5 +467,8 @@ def _get_addon_manager(hass: HomeAssistant) -> AddonManager:
"""
addon_manager: AddonManager = get_addon_manager(hass)
if addon_manager.task_in_progress():
raise ConfigEntryNotReady
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="addon_not_ready",
)
return addon_manager
+5 -4
View File
@@ -33,6 +33,10 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
_LOGGER = logging.getLogger(__name__)
# Replayed history entries older than this are dropped to avoid stale rotating
# MACs from previous commissioning cycles becoming spurious connect candidates.
_MAX_STALE_ADVERTISEMENT_SECONDS = 30
class HaBluetoothScanSource(BleScanSource):
"""`BleScanSource` backed by Home Assistant's bluetooth component.
@@ -54,9 +58,6 @@ class HaBluetoothScanSource(BleScanSource):
if self._cancel is not None:
return
# Drop HA's synchronous replay of stale history on register; otherwise a
# rotating peripheral's old addresses each become a parallel connect candidate.
# `MONOTONIC_TIME` is the clock that stamps `service_info.time`.
scan_start = MONOTONIC_TIME()
@callback
@@ -64,7 +65,7 @@ class HaBluetoothScanSource(BleScanSource):
service_info: BluetoothServiceInfoBleak,
_change: object,
) -> None:
if service_info.time < scan_start:
if scan_start - service_info.time > _MAX_STALE_ADVERTISEMENT_SECONDS:
return
try:
callback_fn(_to_advertisement_data(service_info))
@@ -701,6 +701,15 @@
}
},
"exceptions": {
"addon_not_installed": {
"message": "The Matter Server app is not installed yet."
},
"addon_not_ready": {
"message": "The Matter Server app is not ready yet."
},
"addon_not_running": {
"message": "The Matter Server app is not running yet."
},
"credential_type_not_supported": {
"message": "The lock does not support credential type `{credential_type}`."
},
@@ -712,6 +721,10 @@
}
},
"issues": {
"ipv6_disabled": {
"description": "Matter relies on IPv6 to communicate with some devices, but IPv6 is disabled on all of your connected network interfaces. Locally connected Wi-Fi and Ethernet devices may still work, but features such as using an external Thread border router need IPv6 enabled. Select \"Learn more\" to open the network settings.",
"title": "IPv6 is disabled but required by Matter"
},
"server_version_version_too_new": {
"description": "The version of the Matter Server you are currently running is too new for this version of Home Assistant. Please update Home Assistant or downgrade the Matter Server to an older version to fix this issue.",
"title": "Older version of Matter Server needed"
@@ -112,7 +112,9 @@ from .const import ( # noqa: F401
SERVICE_SELECT_SOURCE,
SERVICE_UNJOIN,
MediaClass,
MediaPlayerEntityCapabilityAttribute,
MediaPlayerEntityFeature,
MediaPlayerEntityStateAttribute,
MediaPlayerState,
MediaType,
RepeatMode,
@@ -201,29 +203,29 @@ MEDIA_PLAYER_BROWSE_MEDIA_SCHEMA = {
ATTR_TO_PROPERTY = [
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_DURATION,
ATTR_MEDIA_POSITION,
ATTR_MEDIA_POSITION_UPDATED_AT,
ATTR_MEDIA_TITLE,
ATTR_MEDIA_ARTIST,
ATTR_MEDIA_ALBUM_NAME,
ATTR_MEDIA_ALBUM_ARTIST,
ATTR_MEDIA_TRACK,
ATTR_MEDIA_SERIES_TITLE,
ATTR_MEDIA_SEASON,
ATTR_MEDIA_EPISODE,
ATTR_MEDIA_CHANNEL,
ATTR_MEDIA_PLAYLIST,
ATTR_APP_ID,
ATTR_APP_NAME,
ATTR_INPUT_SOURCE,
ATTR_SOUND_MODE,
ATTR_MEDIA_SHUFFLE,
ATTR_MEDIA_REPEAT,
MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL,
MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED,
MediaPlayerEntityStateAttribute.MEDIA_CONTENT_ID,
MediaPlayerEntityStateAttribute.MEDIA_CONTENT_TYPE,
MediaPlayerEntityStateAttribute.MEDIA_DURATION,
MediaPlayerEntityStateAttribute.MEDIA_POSITION,
MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT,
MediaPlayerEntityStateAttribute.MEDIA_TITLE,
MediaPlayerEntityStateAttribute.MEDIA_ARTIST,
MediaPlayerEntityStateAttribute.MEDIA_ALBUM_NAME,
MediaPlayerEntityStateAttribute.MEDIA_ALBUM_ARTIST,
MediaPlayerEntityStateAttribute.MEDIA_TRACK,
MediaPlayerEntityStateAttribute.MEDIA_SERIES_TITLE,
MediaPlayerEntityStateAttribute.MEDIA_SEASON,
MediaPlayerEntityStateAttribute.MEDIA_EPISODE,
MediaPlayerEntityStateAttribute.MEDIA_CHANNEL,
MediaPlayerEntityStateAttribute.MEDIA_PLAYLIST,
MediaPlayerEntityStateAttribute.APP_ID,
MediaPlayerEntityStateAttribute.APP_NAME,
MediaPlayerEntityStateAttribute.INPUT_SOURCE,
MediaPlayerEntityStateAttribute.SOUND_MODE,
MediaPlayerEntityStateAttribute.MEDIA_SHUFFLE,
MediaPlayerEntityStateAttribute.MEDIA_REPEAT,
]
# mypy: disallow-any-generics
@@ -540,12 +542,12 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_entity_component_unrecorded_attributes = frozenset(
{
ATTR_ENTITY_PICTURE_LOCAL,
MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL,
ATTR_ENTITY_PICTURE,
ATTR_INPUT_SOURCE_LIST,
ATTR_MEDIA_POSITION_UPDATED_AT,
ATTR_MEDIA_POSITION,
ATTR_SOUND_MODE_LIST,
MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST,
MediaPlayerEntityStateAttribute.MEDIA_POSITION_UPDATED_AT,
MediaPlayerEntityStateAttribute.MEDIA_POSITION,
MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST,
}
)
@@ -1115,12 +1117,12 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if (
source_list := self.source_list
) and MediaPlayerEntityFeature.SELECT_SOURCE in supported_features:
data[ATTR_INPUT_SOURCE_LIST] = source_list
data[MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST] = source_list
if (
sound_mode_list := self.sound_mode_list
) and MediaPlayerEntityFeature.SELECT_SOUND_MODE in supported_features:
data[ATTR_SOUND_MODE_LIST] = sound_mode_list
data[MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST] = sound_mode_list
return data
@@ -1132,7 +1134,9 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
state_attr: dict[str, Any] = {}
if self.support_grouping:
state_attr[ATTR_GROUP_MEMBERS] = self.group_members
state_attr[MediaPlayerEntityStateAttribute.GROUP_MEMBERS] = (
self.group_members
)
if self.state == MediaPlayerState.OFF:
return state_attr
@@ -1142,7 +1146,9 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
state_attr[attr] = value
if self.media_image_remotely_accessible:
state_attr[ATTR_ENTITY_PICTURE_LOCAL] = self.media_image_local
state_attr[MediaPlayerEntityStateAttribute.ENTITY_PICTURE_LOCAL] = (
self.media_image_local
)
return state_attr
@@ -144,6 +144,43 @@ class RepeatMode(StrEnum):
REPEAT_MODES = [cls.value for cls in RepeatMode]
class MediaPlayerEntityCapabilityAttribute(StrEnum):
"""Capability attributes for media player entities."""
INPUT_SOURCE_LIST = "source_list"
SOUND_MODE_LIST = "sound_mode_list"
class MediaPlayerEntityStateAttribute(StrEnum):
"""State attributes for media player entities."""
MEDIA_VOLUME_LEVEL = "volume_level"
MEDIA_VOLUME_MUTED = "is_volume_muted"
MEDIA_CONTENT_ID = "media_content_id"
MEDIA_CONTENT_TYPE = "media_content_type"
MEDIA_DURATION = "media_duration"
MEDIA_POSITION = "media_position"
MEDIA_POSITION_UPDATED_AT = "media_position_updated_at"
MEDIA_TITLE = "media_title"
MEDIA_ARTIST = "media_artist"
MEDIA_ALBUM_NAME = "media_album_name"
MEDIA_ALBUM_ARTIST = "media_album_artist"
MEDIA_TRACK = "media_track"
MEDIA_SERIES_TITLE = "media_series_title"
MEDIA_SEASON = "media_season"
MEDIA_EPISODE = "media_episode"
MEDIA_CHANNEL = "media_channel"
MEDIA_PLAYLIST = "media_playlist"
APP_ID = "app_id"
APP_NAME = "app_name"
INPUT_SOURCE = "source"
SOUND_MODE = "sound_mode"
MEDIA_SHUFFLE = "shuffle"
MEDIA_REPEAT = "repeat"
GROUP_MEMBERS = "group_members"
ENTITY_PICTURE_LOCAL = "entity_picture_local"
class MediaPlayerEntityFeature(IntFlag):
"""Supported features of the media player entity."""
@@ -15,16 +15,29 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info import zeroconf
from .const import CONF_SERIAL, DOMAIN
USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string})
USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): TextSelector()})
AUTH_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string}
{
vol.Required(CONF_USERNAME): TextSelector(
TextSelectorConfig(autocomplete="username")
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD, autocomplete="current-password"
)
),
}
)
+8 -2
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from typing import cast
from aiohttp import ClientError
from pyoverkiz.action_queue import ActionQueueSettings
from pyoverkiz.auth.credentials import (
LocalTokenCredentials,
RexelTokenCredentials,
@@ -317,7 +318,9 @@ def create_local_client(
credentials=LocalTokenCredentials(token),
session=session,
verify_ssl=verify_ssl,
settings=OverkizClientSettings(default_rts_command_duration=0),
settings=OverkizClientSettings(
action_queue=ActionQueueSettings(), default_rts_command_duration=0
),
)
@@ -333,7 +336,9 @@ def create_cloud_client(
server=server,
credentials=UsernamePasswordCredentials(username, password),
session=session,
settings=OverkizClientSettings(default_rts_command_duration=0),
settings=OverkizClientSettings(
action_queue=ActionQueueSettings(), default_rts_command_duration=0
),
)
@@ -360,4 +365,5 @@ async def create_rexel_client(
gateway_id=entry.data[CONF_GATEWAY_ID],
),
session=async_create_clientsession(hass),
settings=OverkizClientSettings(action_queue=ActionQueueSettings()),
)
@@ -76,7 +76,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
self.data = {}
self.client = client
self.devices: dict[str, Device] = {d.device_url: d for d in devices}
self.executions: dict[str, dict[str, str]] = {}
self.executions: dict[str, list[dict[str, str]]] = {}
self.areas = self._places_to_area(places) if places else None
self._default_update_interval = UPDATE_INTERVAL
@@ -228,7 +228,7 @@ async def on_execution_registered(
) -> None:
"""Handle execution registered event."""
if event.exec_id not in coordinator.executions:
coordinator.executions[event.exec_id] = {}
coordinator.executions[event.exec_id] = []
if not coordinator.is_stateless:
coordinator.update_interval = timedelta(seconds=1)
+2 -1
View File
@@ -857,7 +857,8 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
return any(
execution.get("device_url") == self.device.device_url
and execution.get("command_name") == command
for execution in self.coordinator.executions.values()
for executions in self.coordinator.executions.values()
for execution in executions
)
@property
+17 -11
View File
@@ -71,12 +71,15 @@ class OverkizExecutor:
) as exception:
raise HomeAssistantError("Failed to connect") from exception
# ExecutionRegisteredEvent doesn't contain the
# device_url, thus we need to register it here
self.coordinator.executions[exec_id] = {
"device_url": self.device.device_url,
"command_name": command_name,
}
# ExecutionRegisteredEvent doesn't contain the device_url, thus we need
# to register it here. The action queue can merge concurrent action
# groups under one exec_id, so accumulate rather than overwrite.
self.coordinator.executions.setdefault(exec_id, []).append(
{
"device_url": self.device.device_url,
"command_name": command_name,
}
)
if refresh_afterwards:
await self.coordinator.async_refresh()
@@ -110,10 +113,12 @@ class OverkizExecutor:
) as exception:
raise HomeAssistantError("Failed to connect") from exception
self.coordinator.executions[exec_id] = {
"device_url": self.device.device_url,
"command_name": commands[-1].name,
}
self.coordinator.executions.setdefault(exec_id, []).append(
{
"device_url": self.device.device_url,
"command_name": commands[-1].name,
}
)
if refresh_afterwards:
await self.coordinator.async_refresh()
@@ -129,7 +134,8 @@ class OverkizExecutor:
(
exec_id
# Reverse dictionary to cancel the last added execution
for exec_id, execution in reversed(self.coordinator.executions.items())
for exec_id, executions in reversed(self.coordinator.executions.items())
for execution in executions
if execution.get("device_url") == self.device.device_url
and execution.get("command_name") in commands_to_cancel
),
+4 -2
View File
@@ -25,6 +25,8 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import RemoteEntityStateAttribute
_LOGGER = logging.getLogger(__name__)
DOMAIN = "remote"
@@ -186,8 +188,8 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
return None
return {
ATTR_ACTIVITY_LIST: self.activity_list,
ATTR_CURRENT_ACTIVITY: self.current_activity,
RemoteEntityStateAttribute.ACTIVITY_LIST: self.activity_list,
RemoteEntityStateAttribute.CURRENT_ACTIVITY: self.current_activity,
}
def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
+10
View File
@@ -0,0 +1,10 @@
"""Constants for the remote component."""
from enum import StrEnum
class RemoteEntityStateAttribute(StrEnum):
"""State attributes for remote entities."""
ACTIVITY_LIST = "activity_list"
CURRENT_ACTIVITY = "current_activity"
@@ -321,6 +321,7 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = (
options=[
"always",
"delayed",
"delegated",
"scheduled",
],
value_lambda=_get_charging_settings_mode_formatted,
@@ -157,6 +157,7 @@
"state": {
"always": "Always",
"delayed": "Delayed",
"delegated": "Delegated",
"scheduled": "Scheduled"
}
},
+1 -1
View File
@@ -27,7 +27,7 @@
"user": {
"menu_options": {
"cloud": "Risco Cloud (recommended)",
"local": "Local Risco Panel (advanced)"
"local": "Local Risco Panel"
}
}
}
@@ -1,161 +0,0 @@
"""Support for SCSGate components."""
import logging
from threading import Lock
from scsgate.connection import Connection
from scsgate.messages import ScenarioTriggeredMessage, StateMessage
from scsgate.reactor import Reactor
from scsgate.tasks import GetStatusTask
import voluptuous as vol
from homeassistant.const import CONF_DEVICE, CONF_NAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
CONF_SCS_ID = "scs_id"
DOMAIN = "scsgate"
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_DEVICE): cv.string})}, extra=vol.ALLOW_EXTRA
)
SCSGATE_SCHEMA = vol.Schema(
{vol.Required(CONF_SCS_ID): cv.string, vol.Optional(CONF_NAME): cv.string}
)
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the SCSGate component."""
device = config[DOMAIN][CONF_DEVICE]
scsgate = None
try:
scsgate = SCSGate(device=device, logger=_LOGGER)
scsgate.start()
except Exception as exception: # noqa: BLE001
_LOGGER.error("Cannot setup SCSGate component: %s", exception)
return False
def stop_monitor(event):
"""Stop the SCSGate."""
_LOGGER.debug("Stopping SCSGate monitor thread")
scsgate.stop()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_monitor)
hass.data[DOMAIN] = scsgate
return True
class SCSGate:
"""The class for dealing with the SCSGate device via scsgate.Reactor."""
def __init__(self, device, logger):
"""Initialize the SCSGate."""
self._logger = logger
self._devices = {}
self._devices_to_register = {}
self._devices_to_register_lock = Lock()
self._device_being_registered = None
self._device_being_registered_lock = Lock()
connection = Connection(device=device, logger=self._logger)
self._reactor = Reactor(
connection=connection,
logger=self._logger,
handle_message=self.handle_message,
)
def handle_message(self, message):
"""Handle a messages seen on the bus."""
self._logger.debug("Received message %s", message)
if not isinstance(message, StateMessage) and not isinstance(
message, ScenarioTriggeredMessage
):
msg = f"Ignored message {message} - not relevant type"
self._logger.debug(msg)
return
if message.entity in self._devices:
new_device_activated = False
with self._devices_to_register_lock:
if message.entity == self._device_being_registered:
self._device_being_registered = None
new_device_activated = True
if new_device_activated:
self._activate_next_device()
try:
self._devices[message.entity].process_event(message)
except Exception as exception: # noqa: BLE001
msg = f"Exception while processing event: {exception}"
self._logger.error(msg)
else:
self._logger.info(
"Ignoring state message for device %s because unknown", message.entity
)
@property
def devices(self):
"""Return a dictionary with known devices.
Key is device ID, value is the device itself.
"""
return self._devices
def add_device(self, device):
"""Add the specified device.
The list contain already registered ones.
Beware: this is not what you usually want to do, take a look at
`add_devices_to_register`
"""
self._devices[device.scs_id] = device
def add_devices_to_register(self, devices):
"""List of devices to be registered."""
with self._devices_to_register_lock:
for device in devices:
self._devices_to_register[device.scs_id] = device
self._activate_next_device()
def _activate_next_device(self):
"""Start the activation of the first device."""
with self._devices_to_register_lock:
while self._devices_to_register:
device = self._devices_to_register.popitem()[1]
self._devices[device.scs_id] = device
self._device_being_registered = device.scs_id
self._reactor.append_task(GetStatusTask(target=device.scs_id))
def is_device_registered(self, device_id):
"""Check whether a device is already registered or not."""
with self._devices_to_register_lock:
if device_id in self._devices_to_register:
return False
with self._device_being_registered_lock:
if device_id == self._device_being_registered:
return False
return True
def start(self):
"""Start the scsgate.Reactor."""
self._reactor.start()
def stop(self):
"""Stop the scsgate.Reactor."""
self._reactor.stop()
def append_task(self, task):
"""Register a new task to be executed."""
self._reactor.append_task(task)
-107
View File
@@ -1,107 +0,0 @@
"""Support for SCSGate covers."""
import logging
from typing import Any, override
from scsgate.tasks import (
HaltRollerShutterTask,
LowerRollerShutterTask,
RaiseRollerShutterTask,
)
import voluptuous as vol
from homeassistant.components.cover import (
PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA,
CoverEntity,
)
from homeassistant.const import CONF_DEVICES, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_SCS_ID, DOMAIN, SCSGATE_SCHEMA
PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend(
{vol.Required(CONF_DEVICES): cv.schema_with_slug_keys(SCSGATE_SCHEMA)}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the SCSGate cover."""
devices = config.get(CONF_DEVICES)
covers = []
logger = logging.getLogger(__name__)
scsgate = hass.data[DOMAIN]
if devices:
for entity_info in devices.values():
if entity_info[CONF_SCS_ID] in scsgate.devices:
continue
name = entity_info[CONF_NAME]
scs_id = entity_info[CONF_SCS_ID]
logger.info("Adding %s scsgate.cover", name)
cover = SCSGateCover(
name=name, scs_id=scs_id, logger=logger, scsgate=scsgate
)
scsgate.add_device(cover)
covers.append(cover)
add_entities(covers)
class SCSGateCover(CoverEntity):
"""Representation of SCSGate cover."""
_attr_should_poll = False
def __init__(self, scs_id, name, logger, scsgate):
"""Initialize the cover."""
self._scs_id = scs_id
self._name = name
self._logger = logger
self._scsgate = scsgate
@property
def scs_id(self):
"""Return the SCSGate ID."""
return self._scs_id
@property
@override
def name(self) -> str:
"""Return the name of the cover."""
return self._name
@property
@override
def is_closed(self) -> None:
"""Return if the cover is closed."""
return None
@override
def open_cover(self, **kwargs: Any) -> None:
"""Move the cover."""
self._scsgate.append_task(RaiseRollerShutterTask(target=self._scs_id))
@override
def close_cover(self, **kwargs: Any) -> None:
"""Move the cover down."""
self._scsgate.append_task(LowerRollerShutterTask(target=self._scs_id))
@override
def stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
self._scsgate.append_task(HaltRollerShutterTask(target=self._scs_id))
def process_event(self, message):
"""Handle a SCSGate message related with this cover."""
self._logger.debug("Cover %s, got message %s", self._scs_id, message.toggled)
-116
View File
@@ -1,116 +0,0 @@
"""Support for SCSGate lights."""
import logging
from typing import Any, override
from scsgate.tasks import ToggleStatusTask
import voluptuous as vol
from homeassistant.components.light import (
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode,
LightEntity,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_SCS_ID, DOMAIN, SCSGATE_SCHEMA
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
{vol.Required(CONF_DEVICES): cv.schema_with_slug_keys(SCSGATE_SCHEMA)}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the SCSGate switches."""
devices = config.get(CONF_DEVICES)
lights = []
logger = logging.getLogger(__name__)
scsgate = hass.data[DOMAIN]
if devices:
for entity_info in devices.values():
if entity_info[CONF_SCS_ID] in scsgate.devices:
continue
name = entity_info[CONF_NAME]
scs_id = entity_info[CONF_SCS_ID]
logger.info("Adding %s scsgate.light", name)
light = SCSGateLight(
name=name, scs_id=scs_id, logger=logger, scsgate=scsgate
)
lights.append(light)
add_entities(lights)
scsgate.add_devices_to_register(lights)
class SCSGateLight(LightEntity):
"""Representation of a SCSGate light."""
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_should_poll = False
def __init__(self, scs_id, name, logger, scsgate):
"""Initialize the light."""
self._attr_name = name
self._scs_id = scs_id
self._attr_is_on = False
self._logger = logger
self._scsgate = scsgate
@property
def scs_id(self):
"""Return the SCS ID."""
return self._scs_id
@override
def turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
self._scsgate.append_task(ToggleStatusTask(target=self._scs_id, toggled=True))
self._attr_is_on = True
self.schedule_update_ha_state()
@override
def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
self._scsgate.append_task(ToggleStatusTask(target=self._scs_id, toggled=False))
self._attr_is_on = False
self.schedule_update_ha_state()
def process_event(self, message):
"""Handle a SCSGate message related with this light."""
if self._attr_is_on == message.toggled:
self._logger.info(
"Light %s, ignoring message %s because state already active",
self._scs_id,
message,
)
# Nothing changed, ignoring
return
self._attr_is_on = message.toggled
self.schedule_update_ha_state()
command = "off"
if self._attr_is_on:
command = "on"
self.hass.bus.fire(
"button_pressed", {ATTR_ENTITY_ID: self._scs_id, ATTR_STATE: command}
)
@@ -1,10 +0,0 @@
{
"domain": "scsgate",
"name": "SCSGate",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/scsgate",
"iot_class": "local_polling",
"loggers": ["scsgate"],
"quality_scale": "legacy",
"requirements": ["scsgate==0.1.0"]
}
-193
View File
@@ -1,193 +0,0 @@
"""Support for SCSGate switches."""
import logging
from typing import Any, override
from scsgate.messages import ScenarioTriggeredMessage, StateMessage
from scsgate.tasks import ToggleStatusTask
import voluptuous as vol
from homeassistant.components.switch import (
PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA,
SwitchEntity,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_SCS_ID, DOMAIN, SCSGATE_SCHEMA
ATTR_SCENARIO_ID = "scenario_id"
CONF_TRADITIONAL = "traditional"
CONF_SCENARIO = "scenario"
PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
{vol.Required(CONF_DEVICES): cv.schema_with_slug_keys(SCSGATE_SCHEMA)}
)
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the SCSGate switches."""
logger = logging.getLogger(__name__)
scsgate = hass.data[DOMAIN]
_setup_traditional_switches(
logger=logger,
config=config,
scsgate=scsgate,
add_entities_callback=add_entities,
)
_setup_scenario_switches(logger=logger, config=config, scsgate=scsgate, hass=hass)
def _setup_traditional_switches(logger, config, scsgate, add_entities_callback):
"""Add traditional SCSGate switches."""
traditional = config.get(CONF_TRADITIONAL)
switches = []
if traditional:
for entity_info in traditional.values():
if entity_info[CONF_SCS_ID] in scsgate.devices:
continue
name = entity_info[CONF_NAME]
scs_id = entity_info[CONF_SCS_ID]
logger.info("Adding %s scsgate.traditional_switch", name)
switch = SCSGateSwitch(
name=name, scs_id=scs_id, logger=logger, scsgate=scsgate
)
switches.append(switch)
add_entities_callback(switches)
scsgate.add_devices_to_register(switches)
def _setup_scenario_switches(logger, config, scsgate, hass):
"""Add only SCSGate scenario switches."""
if scenario := config.get(CONF_SCENARIO):
for entity_info in scenario.values():
if entity_info[CONF_SCS_ID] in scsgate.devices:
continue
name = entity_info[CONF_NAME]
scs_id = entity_info[CONF_SCS_ID]
logger.info("Adding %s scsgate.scenario_switch", name)
switch = SCSGateScenarioSwitch(
name=name, scs_id=scs_id, logger=logger, hass=hass
)
scsgate.add_device(switch)
class SCSGateSwitch(SwitchEntity):
"""Representation of a SCSGate switch."""
_attr_should_poll = False
def __init__(self, scs_id, name, logger, scsgate):
"""Initialize the switch."""
self._attr_name = name
self._scs_id = scs_id
self._attr_is_on = False
self._logger = logger
self._scsgate = scsgate
@property
def scs_id(self):
"""Return the SCS ID."""
return self._scs_id
@override
def turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
self._scsgate.append_task(ToggleStatusTask(target=self._scs_id, toggled=True))
self._attr_is_on = True
self.schedule_update_ha_state()
@override
def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
self._scsgate.append_task(ToggleStatusTask(target=self._scs_id, toggled=False))
self._attr_is_on = False
self.schedule_update_ha_state()
def process_event(self, message):
"""Handle a SCSGate message related with this switch."""
if self._attr_is_on == message.toggled:
self._logger.info(
"Switch %s, ignoring message %s because state already active",
self._scs_id,
message,
)
# Nothing changed, ignoring
return
self._attr_is_on = message.toggled
self.schedule_update_ha_state()
command = "off"
if self._attr_is_on:
command = "on"
self.hass.bus.fire(
"button_pressed", {ATTR_ENTITY_ID: self._scs_id, ATTR_STATE: command}
)
class SCSGateScenarioSwitch:
"""Provides a SCSGate scenario switch.
This switch is always in an 'off" state, when toggled it's used to trigger
events.
"""
def __init__(self, scs_id, name, logger, hass):
"""Initialize the scenario."""
self._name = name
self._scs_id = scs_id
self._logger = logger
self._hass = hass
@property
def scs_id(self):
"""Return the SCS ID."""
return self._scs_id
@property
def name(self):
"""Return the name of the device if any."""
return self._name
def process_event(self, message):
"""Handle a SCSGate message related with this switch."""
if isinstance(message, StateMessage):
scenario_id = message.bytes[4]
elif isinstance(message, ScenarioTriggeredMessage):
scenario_id = message.scenario
else:
self._logger.warning(
"Scenario switch: received unknown message %s", message
)
return
self._hass.bus.fire(
"scenario_switch_triggered",
{ATTR_ENTITY_ID: int(self._scs_id), ATTR_SCENARIO_ID: int(scenario_id, 16)},
)
@@ -1,7 +1,7 @@
{
"domain": "sensoterra",
"name": "Sensoterra",
"codeowners": ["@markruys"],
"codeowners": ["@SanderBakkumCuriousInc", "@curious-florian", "@markruys"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sensoterra",
"integration_type": "hub",
+8 -3
View File
@@ -16,12 +16,13 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType, VolDictType
from homeassistant.util.hass_dict import HassKey
from .const import (
from .const import ( # noqa: F401
ATTR_AVAILABLE_TONES,
ATTR_DURATION,
ATTR_TONE,
ATTR_VOLUME_LEVEL,
DOMAIN,
SirenEntityCapabilityAttribute,
SirenEntityFeature,
)
@@ -154,7 +155,9 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Representation of a siren device."""
_entity_component_unrecorded_attributes = frozenset({ATTR_AVAILABLE_TONES})
_entity_component_unrecorded_attributes = frozenset(
{SirenEntityCapabilityAttribute.AVAILABLE_TONES}
)
entity_description: SirenEntityDescription
_attr_available_tones: list[int | str] | dict[int, str] | None
@@ -169,7 +172,9 @@ class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self.supported_features & SirenEntityFeature.TONES
and self.available_tones is not None
):
return {ATTR_AVAILABLE_TONES: self.available_tones}
return {
SirenEntityCapabilityAttribute.AVAILABLE_TONES: self.available_tones
}
return None
+7 -1
View File
@@ -1,6 +1,6 @@
"""Constants for the siren component."""
from enum import IntFlag
from enum import IntFlag, StrEnum
from typing import Final
DOMAIN: Final = "siren"
@@ -12,6 +12,12 @@ ATTR_DURATION: Final = "duration"
ATTR_VOLUME_LEVEL: Final = "volume_level"
class SirenEntityCapabilityAttribute(StrEnum):
"""Capability attributes for siren entities."""
AVAILABLE_TONES = "available_tones"
class SirenEntityFeature(IntFlag):
"""Supported features of the siren entity."""
@@ -12,7 +12,7 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["pysmlight==0.3.2"],
"requirements": ["pysmlight==0.4.0"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
+1 -1
View File
@@ -35,7 +35,7 @@
"value_template": "Template to extract a value from the payload (optional)"
},
"description": "Provide additional configuration to the sensor",
"name": "Advanced options"
"name": "Additional options"
}
}
},
@@ -3,14 +3,13 @@
from collections.abc import Iterator, Mapping
from typing import Any, override
import steam
import steam.api
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import callback
@@ -22,6 +21,14 @@ from .coordinator import SteamConfigEntry
# To avoid too long request URIs, the amount of ids to request is limited
MAX_IDS_TO_REQUEST = 275
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_ACCOUNT): str,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
def validate_input(user_input: dict[str, str]) -> dict[str, str | int]:
"""Handle common flow input validation."""
@@ -49,29 +56,23 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors = {}
if user_input is None and self.source == SOURCE_REAUTH:
user_input = {CONF_ACCOUNT: self._get_reauth_entry().data[CONF_ACCOUNT]}
elif user_input is not None:
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_ACCOUNT])
self._abort_if_unique_id_configured()
try:
res = await self.hass.async_add_executor_job(validate_input, user_input)
if res is not None:
name = str(res["personaname"])
else:
errors["base"] = "invalid_account"
except (steam.api.HTTPError, steam.api.HTTPTimeoutError) as ex:
errors["base"] = "cannot_connect"
if "403" in str(ex):
errors["base"] = "invalid_auth"
except steam.api.HTTPError as ex:
errors["base"] = (
"invalid_auth" if "403" in str(ex) else "cannot_connect"
)
except Exception as ex: # noqa: BLE001
LOGGER.exception("Unknown exception: %s", ex)
errors["base"] = "unknown"
if not errors:
entry = await self.async_set_unique_id(user_input[CONF_ACCOUNT])
if entry and self.source == SOURCE_REAUTH:
self.hass.config_entries.async_update_entry(entry, data=user_input)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=name,
data=user_input,
@@ -80,15 +81,8 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
user_input = user_input or {}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_API_KEY, default=user_input.get(CONF_API_KEY) or ""
): str,
vol.Required(
CONF_ACCOUNT, default=user_input.get(CONF_ACCOUNT) or ""
): str,
}
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
description_placeholders=PLACEHOLDERS,
@@ -104,12 +98,34 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is not None:
return await self.async_step_user()
errors: dict[str, str] = {}
entry = self._get_reauth_entry()
self._set_confirm_only()
if user_input is not None:
try:
if not await self.hass.async_add_executor_job(
validate_input, {**entry.data, **user_input}
):
errors["base"] = "invalid_account"
except steam.api.HTTPError as ex:
errors["base"] = (
"invalid_auth" if "403" in str(ex) else "cannot_connect"
)
except Exception as ex: # noqa: BLE001
LOGGER.exception("Unknown exception: %s", ex)
errors["base"] = "unknown"
if not errors:
return self.async_update_reload_and_abort(
entry, data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm", description_placeholders=PLACEHOLDERS
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
description_placeholders=PLACEHOLDERS,
)
@@ -118,7 +134,7 @@ def _batch_ids(ids: list[str]) -> Iterator[list[str]]:
yield ids[i : i + MAX_IDS_TO_REQUEST]
class SteamOptionsFlowHandler(OptionsFlow):
class SteamOptionsFlowHandler(OptionsFlowWithReload):
"""Handle Steam client options."""
def __init__(self, entry: SteamConfigEntry) -> None:
@@ -145,7 +161,6 @@ class SteamOptionsFlowHandler(OptionsFlow):
if _id in user_input[CONF_ACCOUNTS]
}
}
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
return self.async_create_entry(title="", data=channel_data)
error = None
try:
@@ -3,7 +3,7 @@
from datetime import timedelta
from typing import override
import steam
import steam.api
from steam.api import _interface_method as INTMethod
from homeassistant.config_entries import ConfigEntry
@@ -70,7 +70,7 @@ class SteamDataUpdateCoordinator(
try:
return await self.hass.async_add_executor_job(self._update)
except (steam.api.HTTPError, steam.api.HTTPTimeoutError) as ex:
except steam.api.HTTPError as ex:
if "401" in str(ex):
raise ConfigEntryAuthFailed from ex
raise UpdateFailed(ex) from ex
@@ -12,7 +12,13 @@
},
"step": {
"reauth_confirm": {
"description": "The Steam integration needs to be manually re-authenticated\n\nYou can find your Steam Web API key [**here**]({api_key_url}).",
"data": {
"api_key": "[%key:component::steam_online::config::step::user::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::steam_online::config::step::user::data_description::api_key%]"
},
"description": "The Steam integration requires re-authentication.\n\nYou can find your Steam Web API key [**here**]({api_key_url}).",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
+217 -3
View File
@@ -3,23 +3,52 @@
from datetime import datetime, timedelta
from typing import Any, Unpack, cast, override
import astral.sun
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
from homeassistant.const import (
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_TARGET,
CONF_TYPE,
DEGREE,
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.automation import (
DomainSpec,
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.condition import (
ATTR_BEHAVIOR,
BEHAVIOR_ANY,
Condition,
ConditionCheckParams,
ConditionConfig,
EntityNumericalConditionBase,
condition_trace_set_result,
condition_trace_update_result,
)
from homeassistant.helpers.sun import get_astral_event_date
from homeassistant.helpers.selector import (
NumericThresholdMode,
NumericThresholdSelector,
NumericThresholdSelectorConfig,
)
from homeassistant.helpers.sun import get_astral_event_date, get_astral_observer
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import (
DOMAIN,
ELEVATION_ASTRONOMICAL,
ELEVATION_CIVIL,
ELEVATION_HORIZON,
ELEVATION_NAUTICAL,
STATE_ATTR_ELEVATION,
)
_OPTIONS_SCHEMA_DICT: dict[vol.Marker, Any] = {
vol.Optional("before"): cv.sun_event,
vol.Optional("before_offset"): cv.time_period,
@@ -167,8 +196,193 @@ class SunCondition(Condition):
)
# The sun is a singleton, so these conditions take no target and no options.
_STATE_CONDITION_SCHEMA = vol.Schema({vol.Required(CONF_OPTIONS, default=dict): {}})
# The sun is a singleton, so the elevation condition always targets sun.sun
# instead of asking the user to pick an entity.
_SUN_ENTITY_ID = f"{DOMAIN}.{DOMAIN}"
_ELEVATION_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=STATE_ATTR_ELEVATION)}
def _solar_position(hass: HomeAssistant) -> tuple[float, bool]:
"""Return the sun's current elevation in degrees and whether it is rising."""
observer = get_astral_observer(hass)
now = dt_util.utcnow()
elevation = astral.sun.elevation(observer, now)
rising = astral.sun.elevation(observer, now + timedelta(minutes=1)) > elevation
return elevation, rising
class _SunStateCondition(Condition):
"""Base class for the option-less sun state conditions."""
@classmethod
@override
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _STATE_CONDITION_SCHEMA(config))
class _UpCondition(_SunStateCondition):
"""Test if the sun is up."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
elevation, _ = _solar_position(self._hass)
return elevation >= ELEVATION_HORIZON
class _SetCondition(_SunStateCondition):
"""Test if the sun is set."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
elevation, _ = _solar_position(self._hass)
return elevation < ELEVATION_HORIZON
class _AscendingCondition(_SunStateCondition):
"""Test if the sun is ascending."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
_, rising = _solar_position(self._hass)
return rising
class _DescendingCondition(_SunStateCondition):
"""Test if the sun is descending."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
_, rising = _solar_position(self._hass)
return not rising
class _NightCondition(_SunStateCondition):
"""Test if it is night (the sun is below all twilight)."""
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
elevation, _ = _solar_position(self._hass)
return elevation <= ELEVATION_ASTRONOMICAL
_TWILIGHT_ANY = "any"
_TWILIGHT_CIVIL = "civil"
_TWILIGHT_NAUTICAL = "nautical"
_TWILIGHT_ASTRONOMICAL = "astronomical"
# Elevation band (min, max) in degrees for each twilight type, bounded by the
# horizon and the twilight elevations.
_TWILIGHT_BANDS = {
_TWILIGHT_ANY: (ELEVATION_ASTRONOMICAL, ELEVATION_HORIZON),
_TWILIGHT_CIVIL: (ELEVATION_CIVIL, ELEVATION_HORIZON),
_TWILIGHT_NAUTICAL: (ELEVATION_NAUTICAL, ELEVATION_CIVIL),
_TWILIGHT_ASTRONOMICAL: (ELEVATION_ASTRONOMICAL, ELEVATION_NAUTICAL),
}
_TWILIGHT_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default=dict): {
vol.Optional(CONF_TYPE, default=_TWILIGHT_ANY): vol.In(_TWILIGHT_BANDS),
}
}
)
class _TwilightCondition(Condition):
"""Base class for the morning and evening twilight conditions.
The sun is in twilight when its elevation is within the selected band;
morning twilight requires the sun to be rising and evening twilight to be
descending.
"""
_rising: bool
@classmethod
@override
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _TWILIGHT_CONDITION_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config)
assert config.options is not None
self._low, self._high = _TWILIGHT_BANDS[config.options[CONF_TYPE]]
@override
def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool:
"""Check the condition."""
elevation, rising = _solar_position(self._hass)
return rising == self._rising and self._low <= elevation <= self._high
class _MorningTwilightCondition(_TwilightCondition):
"""Test if it is morning twilight (the sun is rising through twilight)."""
_rising = True
class _EveningTwilightCondition(_TwilightCondition):
"""Test if it is evening twilight (the sun is descending through twilight)."""
_rising = False
_ELEVATION_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default=dict): {
vol.Required("threshold"): NumericThresholdSelector(
NumericThresholdSelectorConfig(mode=NumericThresholdMode.IS)
),
}
}
)
class _ElevationCondition(EntityNumericalConditionBase):
"""Test the sun's elevation against a threshold."""
_domain_specs = _ELEVATION_DOMAIN_SPECS
_valid_unit = DEGREE
_schema = _ELEVATION_CONDITION_SCHEMA
@classmethod
@override
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config and target the singleton sun entity."""
config = cast(ConfigType, cls._schema(config))
config[CONF_TARGET] = {CONF_ENTITY_ID: [_SUN_ENTITY_ID]}
# `behavior` is needed by `EntityConditionBase.__init__`.
config[CONF_OPTIONS][ATTR_BEHAVIOR] = BEHAVIOR_ANY
return config
CONDITIONS: dict[str, type[Condition]] = {
"_": SunCondition,
"is_up": _UpCondition,
"is_set": _SetCondition,
"is_ascending": _AscendingCondition,
"is_descending": _DescendingCondition,
"elevation": _ElevationCondition,
"is_night": _NightCondition,
"is_morning_twilight": _MorningTwilightCondition,
"is_evening_twilight": _EveningTwilightCondition,
}
@@ -0,0 +1,46 @@
.type: &condition_type
required: true
default: any
selector:
select:
translation_key: twilight_type
options:
- any
- civil
- nautical
- astronomical
.elevation_threshold_entity: &condition_elevation_threshold_entity
- domain: input_number
unit_of_measurement: "°"
- domain: number
unit_of_measurement: "°"
- domain: sensor
unit_of_measurement: "°"
.elevation_threshold_number: &condition_elevation_threshold_number
min: -90
max: 90
mode: box
unit_of_measurement: "°"
is_up: {}
is_set: {}
is_ascending: {}
is_descending: {}
is_night: {}
elevation:
fields:
threshold:
required: true
selector:
numeric_threshold:
entity: *condition_elevation_threshold_entity
mode: is
number: *condition_elevation_threshold_number
is_morning_twilight:
fields:
type: *condition_type
is_evening_twilight:
fields:
type: *condition_type
+11
View File
@@ -2,10 +2,21 @@
from typing import Final
import astral
DOMAIN: Final = "sun"
DEFAULT_NAME: Final = "Sun"
# Elevation of the sun's center at the horizon, in degrees. This is the value
# astral uses for sunrise/sunset (atmospheric refraction plus the sun's radius).
ELEVATION_HORIZON: Final = -0.833
# Sun elevation, in degrees, at each twilight boundary
ELEVATION_CIVIL: Final[float] = -astral.Depression.CIVIL.value
ELEVATION_NAUTICAL: Final[float] = -astral.Depression.NAUTICAL.value
ELEVATION_ASTRONOMICAL: Final[float] = -astral.Depression.ASTRONOMICAL.value
SIGNAL_POSITION_CHANGED = f"{DOMAIN}_position_changed"
SIGNAL_EVENTS_CHANGED = f"{DOMAIN}_events_changed"
+22 -20
View File
@@ -24,6 +24,10 @@ from homeassistant.helpers.sun import (
from homeassistant.util import dt as dt_util
from .const import (
ELEVATION_ASTRONOMICAL,
ELEVATION_CIVIL,
ELEVATION_HORIZON,
ELEVATION_NAUTICAL,
SIGNAL_EVENTS_CHANGED,
SIGNAL_POSITION_CHANGED,
STATE_ABOVE_HORIZON,
@@ -67,12 +71,8 @@ PHASE_SMALL_DAY = "small_day"
# > 10° above horizon
PHASE_DAY = "day"
# Depression angle (degrees below the horizon) of the sun at each dawn/dusk
# phase boundary. A negative value means the sun is above the horizon.
DEPRESSION_ASTRONOMICAL = 18.0
DEPRESSION_NAUTICAL = 12.0
DEPRESSION_CIVIL = 6.0
DEPRESSION_SMALL_DAY = -10.0
# Sun elevation (degrees above the horizon) at the start of the "small day" phase.
_ELEVATION_SMALL_DAY = 10.0
# 4 mins is one degree of arc change of the sun on its circle.
# During the night and the middle of the day we don't update
@@ -162,8 +162,7 @@ class Sun(Entity):
@override
def state(self) -> str:
"""Return the state of the sun."""
# 0.8333 is the same value as astral uses
if self.solar_elevation > -0.833:
if self.solar_elevation > ELEVATION_HORIZON:
return STATE_ABOVE_HORIZON
return STATE_BELOW_HORIZON
@@ -189,8 +188,11 @@ class Sun(Entity):
utc_point_in_time: datetime,
sun_event: str,
before: str | None,
depression: float | None = None,
elevation: float | None = None,
) -> datetime:
# astral takes a depression (degrees below the horizon), i.e. the
# negated elevation.
depression = None if elevation is None else -elevation
next_utc = get_observer_astral_event_next(
self.observer, sun_event, utc_point_in_time, depression=depression
)
@@ -209,36 +211,36 @@ class Sun(Entity):
# Work our way around the solar cycle, figure out the next
# phase. Some of these are stored.
self._check_event(
utc_point_in_time, "dawn", PHASE_NIGHT, DEPRESSION_ASTRONOMICAL
utc_point_in_time, "dawn", PHASE_NIGHT, ELEVATION_ASTRONOMICAL
)
self._check_event(
utc_point_in_time, "dawn", PHASE_ASTRONOMICAL_TWILIGHT, DEPRESSION_NAUTICAL
utc_point_in_time, "dawn", PHASE_ASTRONOMICAL_TWILIGHT, ELEVATION_NAUTICAL
)
self.next_dawn = self._check_event(
utc_point_in_time, "dawn", PHASE_NAUTICAL_TWILIGHT, DEPRESSION_CIVIL
utc_point_in_time, "dawn", PHASE_NAUTICAL_TWILIGHT, ELEVATION_CIVIL
)
self.next_rising = self._check_event(
utc_point_in_time, SUN_EVENT_SUNRISE, PHASE_TWILIGHT
)
self._check_event(
utc_point_in_time, "dawn", PHASE_SMALL_DAY, DEPRESSION_SMALL_DAY
utc_point_in_time, "dawn", PHASE_SMALL_DAY, _ELEVATION_SMALL_DAY
)
self.next_noon = self._check_event(utc_point_in_time, "noon", None)
self._check_event(utc_point_in_time, "dusk", PHASE_DAY, DEPRESSION_SMALL_DAY)
self._check_event(utc_point_in_time, "dusk", PHASE_DAY, _ELEVATION_SMALL_DAY)
self.next_setting = self._check_event(
utc_point_in_time, SUN_EVENT_SUNSET, PHASE_SMALL_DAY
)
self.next_dusk = self._check_event(
utc_point_in_time, "dusk", PHASE_TWILIGHT, DEPRESSION_CIVIL
utc_point_in_time, "dusk", PHASE_TWILIGHT, ELEVATION_CIVIL
)
self._check_event(
utc_point_in_time, "dusk", PHASE_NAUTICAL_TWILIGHT, DEPRESSION_NAUTICAL
utc_point_in_time, "dusk", PHASE_NAUTICAL_TWILIGHT, ELEVATION_NAUTICAL
)
self._check_event(
utc_point_in_time,
"dusk",
PHASE_ASTRONOMICAL_TWILIGHT,
DEPRESSION_ASTRONOMICAL,
ELEVATION_ASTRONOMICAL,
)
self.next_midnight = self._check_event(utc_point_in_time, "midnight", None)
@@ -252,11 +254,11 @@ class Sun(Entity):
self.phase = PHASE_DAY
elif elevation >= 0:
self.phase = PHASE_SMALL_DAY
elif elevation >= -6:
elif elevation >= ELEVATION_CIVIL:
self.phase = PHASE_TWILIGHT
elif elevation >= -12:
elif elevation >= ELEVATION_NAUTICAL:
self.phase = PHASE_NAUTICAL_TWILIGHT
elif elevation >= -18:
elif elevation >= ELEVATION_ASTRONOMICAL:
self.phase = PHASE_ASTRONOMICAL_TWILIGHT
else:
self.phase = PHASE_NIGHT
+26
View File
@@ -1,4 +1,30 @@
{
"conditions": {
"elevation": {
"condition": "mdi:sun-angle"
},
"is_ascending": {
"condition": "mdi:weather-sunset-up"
},
"is_descending": {
"condition": "mdi:weather-sunset-down"
},
"is_evening_twilight": {
"condition": "mdi:weather-sunset-down"
},
"is_morning_twilight": {
"condition": "mdi:weather-sunset-up"
},
"is_night": {
"condition": "mdi:weather-night"
},
"is_set": {
"condition": "mdi:weather-sunny-off"
},
"is_up": {
"condition": "mdi:weather-sunny"
}
},
"entity": {
"binary_sensor": {
"solar_rising": {
+53
View File
@@ -1,10 +1,62 @@
{
"common": {
"condition_threshold_name": "Threshold type",
"trigger_for_name": "For at least",
"trigger_threshold_name": "Threshold type",
"twilight_type_description": "The phase of twilight.",
"twilight_type_name": "Twilight type"
},
"conditions": {
"elevation": {
"description": "Tests the sun's elevation against a threshold you set.",
"fields": {
"threshold": {
"name": "[%key:component::sun::common::condition_threshold_name%]"
}
},
"name": "Sun elevation"
},
"is_ascending": {
"description": "Tests if the sun is ascending.",
"name": "Sun is ascending"
},
"is_descending": {
"description": "Tests if the sun is descending.",
"name": "Sun is descending"
},
"is_evening_twilight": {
"description": "Tests if it is evening twilight, optionally of a specific type.",
"fields": {
"type": {
"description": "[%key:component::sun::common::twilight_type_description%]",
"name": "[%key:component::sun::common::twilight_type_name%]"
}
},
"name": "It is evening twilight"
},
"is_morning_twilight": {
"description": "Tests if it is morning twilight, optionally of a specific type.",
"fields": {
"type": {
"description": "[%key:component::sun::common::twilight_type_description%]",
"name": "[%key:component::sun::common::twilight_type_name%]"
}
},
"name": "It is morning twilight"
},
"is_night": {
"description": "Tests if it is night.",
"name": "It is night"
},
"is_set": {
"description": "Tests if the sun is set.",
"name": "Sun is set"
},
"is_up": {
"description": "Tests if the sun is up.",
"name": "Sun is up"
}
},
"config": {
"step": {
"user": {
@@ -45,6 +97,7 @@
"selector": {
"twilight_type": {
"options": {
"any": "Any",
"astronomical": "Astronomical",
"civil": "Civil",
"nautical": "Nautical"
+17 -10
View File
@@ -3,7 +3,6 @@
from datetime import datetime, timedelta
from typing import Any, cast, override
import astral
import voluptuous as vol
from homeassistant.const import (
@@ -47,7 +46,13 @@ from homeassistant.helpers.trigger import (
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import DOMAIN, STATE_ATTR_ELEVATION
from .const import (
DOMAIN,
ELEVATION_ASTRONOMICAL,
ELEVATION_CIVIL,
ELEVATION_NAUTICAL,
STATE_ATTR_ELEVATION,
)
# Names of solar events supported by the astral.sun module
_SUN_EVENT_SOLAR_NOON = "noon"
@@ -59,11 +64,11 @@ _TWILIGHT_CIVIL = "civil"
_TWILIGHT_NAUTICAL = "nautical"
_TWILIGHT_ASTRONOMICAL = "astronomical"
# Sun depression below the horizon for each twilight phase, as defined by astral.
_TWILIGHT_DEPRESSIONS = {
_TWILIGHT_CIVIL: astral.Depression.CIVIL,
_TWILIGHT_NAUTICAL: astral.Depression.NAUTICAL,
_TWILIGHT_ASTRONOMICAL: astral.Depression.ASTRONOMICAL,
# Sun elevation at each twilight boundary.
_TWILIGHT_ELEVATIONS = {
_TWILIGHT_CIVIL: ELEVATION_CIVIL,
_TWILIGHT_NAUTICAL: ELEVATION_NAUTICAL,
_TWILIGHT_ASTRONOMICAL: ELEVATION_ASTRONOMICAL,
}
# The sun is a singleton, so the elevation triggers always target sun.sun
@@ -228,7 +233,7 @@ _DAWN_DUSK_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default=dict): {
vol.Optional(CONF_TYPE, default=_TWILIGHT_CIVIL): vol.In(
_TWILIGHT_DEPRESSIONS
_TWILIGHT_ELEVATIONS
),
}
}
@@ -244,7 +249,7 @@ class SunDawnDuskTrigger(SunEventTrigger):
"""Initialize the trigger."""
super().__init__(hass, config)
self._twilight: str = self._options[CONF_TYPE]
self._depression = _TWILIGHT_DEPRESSIONS[self._twilight]
self._elevation = _TWILIGHT_ELEVATIONS[self._twilight]
@override
def _get_next_event(self, utc_point_in_time: datetime) -> datetime:
@@ -252,7 +257,9 @@ class SunDawnDuskTrigger(SunEventTrigger):
get_astral_observer(self._hass),
self._event,
utc_point_in_time,
depression=self._depression,
# astral takes a depression (degrees below the horizon), i.e. the
# negated elevation.
depression=-self._elevation,
)
@override
@@ -67,12 +67,13 @@ class Tami4ConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
otp = user_input["otp"]
try:
refresh_token = await self.hass.async_add_executor_job(
Tami4EdgeAPI.submit_otp, self.phone, otp
)
# pylint: disable-next=home-assistant-sequential-executor-jobs
api = await self.hass.async_add_executor_job(
Tami4EdgeAPI, refresh_token
def _submit_otp_and_create_api() -> tuple[str, Tami4EdgeAPI]:
refresh_token = Tami4EdgeAPI.submit_otp(self.phone, otp)
return refresh_token, Tami4EdgeAPI(refresh_token)
refresh_token, api = await self.hass.async_add_executor_job(
_submit_otp_and_create_api
)
except exceptions.OTPFailedException:
errors["base"] = "invalid_auth"
@@ -67,7 +67,7 @@
"api_endpoint": "Telegram bot API server endpoint.\nThe bot will be **locked out for 10 minutes** if you switch back to the default.\nDefault: `{default_api_endpoint}`.",
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n({socks_url})"
},
"name": "Advanced settings"
"name": "Additional settings"
}
}
},

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