mirror of
https://github.com/home-assistant/core.git
synced 2026-06-24 23:55:21 +02:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8630f5570 | |||
| 2a75b0e2fb | |||
| 9c4ad761c4 | |||
| 8e3e1044a1 | |||
| bec6c94e32 | |||
| c9729df69a | |||
| 70ff0fd682 | |||
| 258ae6d506 | |||
| 4f93afd6ae | |||
| 7968fc4809 | |||
| 975f2a831e | |||
| cc2944d626 | |||
| 548ec5cacf | |||
| dc6eef2844 | |||
| 0808e30e37 | |||
| f0ed257f47 | |||
| b4b710b474 | |||
| 0004a82fe4 | |||
| 0c4bc95bdd | |||
| 5fdab795e8 | |||
| 2193665909 | |||
| c9d91d5812 | |||
| de9d9c66c1 | |||
| dfcc4d1ae4 | |||
| d71812f09b | |||
| a323ebe634 | |||
| 024bba55cf | |||
| a5546566e7 | |||
| 3d9994ee4f | |||
| c542f38387 | |||
| 49d6166b7e | |||
| 7249190c64 | |||
| cebdde6ab4 | |||
| 031f4cd965 | |||
| a734f7110c | |||
| 395e949591 | |||
| 484e60a1c4 | |||
| b7a234fbd9 | |||
| a1982fbd54 | |||
| c384cd9894 | |||
| 1aefd2a5ac | |||
| e3605be5cd | |||
| e87a41a01d | |||
| 190ff034aa | |||
| b301925687 | |||
| 7a0f5b066e | |||
| 308fad166d | |||
| 1305c2978c | |||
| 955ad6db1b | |||
| 87dc013803 | |||
| 1bb41cb2dd | |||
| 277af6c60b | |||
| 69e18aa580 | |||
| 75852fc191 | |||
| a661b678a2 | |||
| bd0951110d | |||
| 899f904cf3 | |||
| d2e7426aa5 | |||
| c0e02457bc | |||
| e7562b50cf | |||
| c36e4a03e0 | |||
| 71430af6ff | |||
| 815cce5a0c | |||
| 32929755eb | |||
| 88d4d1c879 | |||
| 51bd71d096 | |||
| 1fcf9eb5b7 | |||
| 1917a007f8 | |||
| b095baa65a | |||
| 2bd81c7351 | |||
| a576aef9a4 | |||
| c2e780dfd2 | |||
| 687064d5cc |
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"resolver_ipv6": "Resolver used for the IPv6 lookup."
|
||||
},
|
||||
"description": "Optionally change resolvers and ports.",
|
||||
"name": "Advanced options"
|
||||
"name": "Additional options"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user