forked from home-assistant/core
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f4220f4e | ||
|
|
fc43ee772c | ||
|
|
d756936a4e | ||
|
|
674a59f138 | ||
|
|
9173aef1ef | ||
|
|
0407fc4581 | ||
|
|
ec4835ef04 | ||
|
|
a3950937e0 | ||
|
|
58b7f9a032 | ||
|
|
cd0656bab0 | ||
|
|
7402dc824e | ||
|
|
67fc1ac40a | ||
|
|
e692d2e284 | ||
|
|
4ac7d68552 | ||
|
|
e53a072e8a | ||
|
|
787f55e513 | ||
|
|
0a11a623a5 | ||
|
|
55ef33af26 | ||
|
|
5c2ef50fca | ||
|
|
630f731020 | ||
|
|
219d1a8a1e | ||
|
|
75641b6cd4 | ||
|
|
340da786af | ||
|
|
7f43064f36 | ||
|
|
97b6912856 | ||
|
|
8b270cb487 | ||
|
|
8232a780eb | ||
|
|
34c30f5ab9 | ||
|
|
75aea68b75 | ||
|
|
3a2beb2212 | ||
|
|
bccdb29edc | ||
|
|
8b4cf288e3 | ||
|
|
1b61d72eaf | ||
|
|
fdaaed6523 | ||
|
|
4e29bdf715 | ||
|
|
a23b427025 | ||
|
|
c185e636ed | ||
|
|
99d39b203f | ||
|
|
e4f1fa48d3 | ||
|
|
c56caca182 | ||
|
|
62d77a135b | ||
|
|
b231eea0c6 | ||
|
|
5eaa15138c | ||
|
|
d401faac7c | ||
|
|
674e02914b | ||
|
|
326ffdcd49 | ||
|
|
b5e24048db | ||
|
|
b1c07ac17a | ||
|
|
a5693c083f | ||
|
|
60e170c863 | ||
|
|
533ae85a49 | ||
|
|
1555f706e5 | ||
|
|
f052c3ca74 | ||
|
|
89e87119f2 | ||
|
|
9e99ea68fb | ||
|
|
b105b0fbcb | ||
|
|
98c3bc56b5 | ||
|
|
06e7f71891 | ||
|
|
55ae0228a9 | ||
|
|
5c882429d4 | ||
|
|
51a4c98562 | ||
|
|
620d2ed8fd | ||
|
|
bdc4171e37 | ||
|
|
f738d39ad9 | ||
|
|
a34e72f3a1 | ||
|
|
c45313e9de | ||
|
|
4d81d056da | ||
|
|
3f53022b50 | ||
|
|
d31a0c8dca | ||
|
|
cc79c3d6e1 | ||
|
|
5e7174a5f4 | ||
|
|
8259ce9868 | ||
|
|
4418e6c4b6 | ||
|
|
02452c7632 | ||
|
|
20f77ef832 | ||
|
|
adbcd8adb4 | ||
|
|
bd069966f2 | ||
|
|
986a86ebed | ||
|
|
01eae3687a | ||
|
|
2f570fa715 | ||
|
|
2ba285b8e5 | ||
|
|
07f4efcd83 | ||
|
|
357fe2a722 | ||
|
|
2accc4c07d | ||
|
|
79b4f8ce0c | ||
|
|
e233024533 | ||
|
|
43527d8d19 | ||
|
|
59471a6fbd | ||
|
|
f5d18108d0 | ||
|
|
267057c989 | ||
|
|
5080246fb6 | ||
|
|
a3abe7456e | ||
|
|
14c6b8d41f | ||
|
|
ea709912d4 | ||
|
|
cb5658d7dc | ||
|
|
7b1cad223d | ||
|
|
e80fd4fc78 | ||
|
|
88d723736f | ||
|
|
dc33d5db82 | ||
|
|
7ffc60fb2c |
@@ -129,8 +129,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/binary_sensor/ @home-assistant/core
|
||||
/tests/components/binary_sensor/ @home-assistant/core
|
||||
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
|
||||
/homeassistant/components/blebox/ @bbx-a @bbx-jp @riokuu
|
||||
/tests/components/blebox/ @bbx-a @bbx-jp @riokuu
|
||||
/homeassistant/components/blebox/ @bbx-a @riokuu
|
||||
/tests/components/blebox/ @bbx-a @riokuu
|
||||
/homeassistant/components/blink/ @fronzbot
|
||||
/tests/components/blink/ @fronzbot
|
||||
/homeassistant/components/blueprint/ @home-assistant/core
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from math import ceil
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from pyairvisual import CloudAPI, NodeSamba
|
||||
from pyairvisual.errors import (
|
||||
@@ -12,6 +12,7 @@ from pyairvisual.errors import (
|
||||
InvalidKeyError,
|
||||
KeyExpiredError,
|
||||
NodeProError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -210,9 +211,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
|
||||
try:
|
||||
data = await api_coro
|
||||
return cast(dict[str, Any], data)
|
||||
except (InvalidKeyError, KeyExpiredError) as ex:
|
||||
return await api_coro
|
||||
except (InvalidKeyError, KeyExpiredError, UnauthorizedError) as ex:
|
||||
raise ConfigEntryAuthFailed from ex
|
||||
except AirVisualError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
@@ -253,8 +253,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async with NodeSamba(
|
||||
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD]
|
||||
) as node:
|
||||
data = await node.async_get_latest_measurements()
|
||||
return cast(dict[str, Any], data)
|
||||
return await node.async_get_latest_measurements()
|
||||
except NodeProError as err:
|
||||
raise UpdateFailed(f"Error while retrieving data: {err}") from err
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ from pyairvisual import CloudAPI, NodeSamba
|
||||
from pyairvisual.errors import (
|
||||
AirVisualError,
|
||||
InvalidKeyError,
|
||||
KeyExpiredError,
|
||||
NodeProError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -119,7 +121,7 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input[CONF_API_KEY] not in valid_keys:
|
||||
try:
|
||||
await coro
|
||||
except InvalidKeyError:
|
||||
except (InvalidKeyError, KeyExpiredError, UnauthorizedError):
|
||||
errors[CONF_API_KEY] = "invalid_api_key"
|
||||
except NotFoundError:
|
||||
errors[CONF_CITY] = "location_not_found"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "AirVisual",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/airvisual",
|
||||
"requirements": ["pyairvisual==5.0.9"],
|
||||
"requirements": ["pyairvisual==2022.07.0"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyairvisual", "pysmb"]
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CLIENT_ID, DOMAIN
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,7 +23,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up platform from a ConfigEntry."""
|
||||
username = entry.data[CONF_USERNAME]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
acc = AladdinConnectClient(username, password, async_get_clientsession(hass))
|
||||
acc = AladdinConnectClient(
|
||||
username, password, async_get_clientsession(hass), CLIENT_ID
|
||||
)
|
||||
try:
|
||||
if not await acc.login():
|
||||
raise ConfigEntryAuthFailed("Incorrect Password")
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CLIENT_ID, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,7 +38,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
acc = AladdinConnectClient(
|
||||
data[CONF_USERNAME], data[CONF_PASSWORD], async_get_clientsession(hass)
|
||||
data[CONF_USERNAME],
|
||||
data[CONF_PASSWORD],
|
||||
async_get_clientsession(hass),
|
||||
CLIENT_ID,
|
||||
)
|
||||
login = await acc.login()
|
||||
await acc.close()
|
||||
|
||||
@@ -18,3 +18,4 @@ STATES_MAP: Final[dict[str, str]] = {
|
||||
|
||||
DOMAIN = "aladdin_connect"
|
||||
SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
|
||||
CLIENT_ID = "1000"
|
||||
|
||||
@@ -88,6 +88,7 @@ class AladdinDevice(CoverEntity):
|
||||
self._device_id = device["device_id"]
|
||||
self._number = device["door_number"]
|
||||
self._attr_name = device["name"]
|
||||
self._serial = device["serial"]
|
||||
self._attr_unique_id = f"{self._device_id}-{self._number}"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -97,8 +98,8 @@ class AladdinDevice(CoverEntity):
|
||||
"""Schedule a state update."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._acc.register_callback(update_callback, self._number)
|
||||
await self._acc.get_doors(self._number)
|
||||
self._acc.register_callback(update_callback, self._serial)
|
||||
await self._acc.get_doors(self._serial)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Close Aladdin Connect before removing."""
|
||||
@@ -114,7 +115,7 @@ class AladdinDevice(CoverEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update status of cover."""
|
||||
await self._acc.get_doors(self._number)
|
||||
await self._acc.get_doors(self._serial)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "aladdin_connect",
|
||||
"name": "Aladdin Connect",
|
||||
"documentation": "https://www.home-assistant.io/integrations/aladdin_connect",
|
||||
"requirements": ["AIOAladdinConnect==0.1.21"],
|
||||
"requirements": ["AIOAladdinConnect==0.1.27"],
|
||||
"codeowners": ["@mkmer"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aladdin_connect"],
|
||||
|
||||
@@ -11,3 +11,4 @@ class DoorDevice(TypedDict):
|
||||
door_number: int
|
||||
name: str
|
||||
status: str
|
||||
serial: str
|
||||
|
||||
@@ -86,7 +86,9 @@ async def async_enable_proactive_mode(hass, smart_home_config):
|
||||
return
|
||||
|
||||
if should_doorbell:
|
||||
if new_state.state == STATE_ON:
|
||||
if new_state.state == STATE_ON and (
|
||||
old_state is None or old_state.state != STATE_ON
|
||||
):
|
||||
await async_send_doorbell_event_message(
|
||||
hass, smart_home_config, alexa_changed_entity
|
||||
)
|
||||
|
||||
@@ -281,14 +281,14 @@ SENSOR_DESCRIPTIONS = (
|
||||
name="Lightning Strikes Per Day",
|
||||
icon="mdi:lightning-bolt",
|
||||
native_unit_of_measurement="strikes",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_LIGHTNING_PER_HOUR,
|
||||
name="Lightning Strikes Per Hour",
|
||||
icon="mdi:lightning-bolt",
|
||||
native_unit_of_measurement="strikes",
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key=TYPE_MAXDAILYGUST,
|
||||
|
||||
@@ -87,7 +87,7 @@ class ArubaDeviceScanner(DeviceScanner):
|
||||
def get_aruba_data(self):
|
||||
"""Retrieve data from Aruba Access Point and return parsed result."""
|
||||
|
||||
connect = f"ssh {self.username}@{self.host}"
|
||||
connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa"
|
||||
ssh = pexpect.spawn(connect)
|
||||
query = ssh.expect(
|
||||
[
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
from homeassistant.components.cover import CoverDeviceClass
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.components.switch import SwitchDeviceClass
|
||||
from homeassistant.const import (
|
||||
STATE_CLOSED,
|
||||
STATE_CLOSING,
|
||||
STATE_OPEN,
|
||||
STATE_OPENING,
|
||||
TEMP_CELSIUS,
|
||||
)
|
||||
from homeassistant.const import TEMP_CELSIUS
|
||||
|
||||
DOMAIN = "blebox"
|
||||
PRODUCT = "product"
|
||||
@@ -30,19 +24,6 @@ BLEBOX_TO_HASS_DEVICE_CLASSES = {
|
||||
"temperature": SensorDeviceClass.TEMPERATURE,
|
||||
}
|
||||
|
||||
BLEBOX_TO_HASS_COVER_STATES = {
|
||||
None: None,
|
||||
0: STATE_CLOSING, # moving down
|
||||
1: STATE_OPENING, # moving up
|
||||
2: STATE_OPEN, # manually stopped
|
||||
3: STATE_CLOSED, # lower limit
|
||||
4: STATE_OPEN, # upper limit / open
|
||||
# gateController
|
||||
5: STATE_OPEN, # overload
|
||||
6: STATE_OPEN, # motor failure
|
||||
# 7 is not used
|
||||
8: STATE_OPEN, # safety stop
|
||||
}
|
||||
|
||||
BLEBOX_TO_UNIT_MAP = {"celsius": TEMP_CELSIUS}
|
||||
|
||||
|
||||
@@ -9,12 +9,26 @@ from homeassistant.components.cover import (
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING
|
||||
from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import BleBoxEntity, create_blebox_entities
|
||||
from .const import BLEBOX_TO_HASS_COVER_STATES, BLEBOX_TO_HASS_DEVICE_CLASSES
|
||||
from .const import BLEBOX_TO_HASS_DEVICE_CLASSES
|
||||
|
||||
BLEBOX_TO_HASS_COVER_STATES = {
|
||||
None: None,
|
||||
0: STATE_CLOSING, # moving down
|
||||
1: STATE_OPENING, # moving up
|
||||
2: STATE_OPEN, # manually stopped
|
||||
3: STATE_CLOSED, # lower limit
|
||||
4: STATE_OPEN, # upper limit / open
|
||||
# gateController
|
||||
5: STATE_OPEN, # overload
|
||||
6: STATE_OPEN, # motor failure
|
||||
# 7 is not used
|
||||
8: STATE_OPEN, # safety stop
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from blebox_uniapi.error import BadOnValueError
|
||||
import blebox_uniapi.light
|
||||
from blebox_uniapi.light import BleboxColorMode
|
||||
|
||||
@@ -160,16 +159,21 @@ class BleBoxLightEntity(BleBoxEntity, LightEntity):
|
||||
else:
|
||||
value = feature.apply_brightness(value, brightness)
|
||||
|
||||
try:
|
||||
await self._feature.async_on(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Turning on '{self.name}' failed: Bad value {value}"
|
||||
) from exc
|
||||
|
||||
if effect is not None:
|
||||
effect_value = self.effect_list.index(effect)
|
||||
await self._feature.async_api_command("effect", effect_value)
|
||||
else:
|
||||
try:
|
||||
await self._feature.async_on(value)
|
||||
except BadOnValueError as ex:
|
||||
_LOGGER.error(
|
||||
"Turning on '%s' failed: Bad value %s (%s)", self.name, value, ex
|
||||
)
|
||||
effect_value = self.effect_list.index(effect)
|
||||
await self._feature.async_api_command("effect", effect_value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Turning on with effect '{self.name}' failed: {effect} not in effect list."
|
||||
) from exc
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn the light off."""
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"name": "BleBox devices",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/blebox",
|
||||
"requirements": ["blebox_uniapi==2.0.0"],
|
||||
"codeowners": ["@bbx-a", "@bbx-jp", "@riokuu"],
|
||||
"requirements": ["blebox_uniapi==2.0.1"],
|
||||
"codeowners": ["@bbx-a", "@riokuu"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["blebox_uniapi"]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
entry.data[CONF_PASSWORD],
|
||||
get_region_from_name(entry.data[CONF_REGION]),
|
||||
observer_position=GPSPosition(hass.config.latitude, hass.config.longitude),
|
||||
use_metric_units=hass.config.units.is_metric,
|
||||
# Force metric system as BMW API apparently only returns metric values now
|
||||
use_metric_units=True,
|
||||
)
|
||||
self.read_only = entry.options[CONF_READ_ONLY]
|
||||
self._entry = entry
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "bmw_connected_drive",
|
||||
"name": "BMW Connected Drive",
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"requirements": ["bimmer_connected==0.9.6"],
|
||||
"requirements": ["bimmer_connected==0.10.1"],
|
||||
"codeowners": ["@gerard33", "@rikroe"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
|
||||
@@ -16,7 +16,6 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_UNIT_SYSTEM_IMPERIAL,
|
||||
LENGTH_KILOMETERS,
|
||||
LENGTH_MILES,
|
||||
PERCENTAGE,
|
||||
@@ -183,10 +182,8 @@ class BMWSensor(BMWBaseEntity, SensorEntity):
|
||||
self._attr_name = f"{vehicle.name} {description.key}"
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
|
||||
if unit_system.name == CONF_UNIT_SYSTEM_IMPERIAL:
|
||||
self._attr_native_unit_of_measurement = description.unit_imperial
|
||||
else:
|
||||
self._attr_native_unit_of_measurement = description.unit_metric
|
||||
# Force metric system as BMW API apparently only returns metric values now
|
||||
self._attr_native_unit_of_measurement = description.unit_metric
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
|
||||
@@ -3,7 +3,6 @@ from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp.hdrs import CONTENT_TYPE
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -20,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BASE_API_URL = "https://rest.clicksend.com/v3"
|
||||
|
||||
HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON}
|
||||
HEADERS = {"Content-Type": CONTENT_TYPE_JSON}
|
||||
|
||||
CONF_LANGUAGE = "language"
|
||||
CONF_VOICE = "voice"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "cloud",
|
||||
"name": "Home Assistant Cloud",
|
||||
"documentation": "https://www.home-assistant.io/integrations/cloud",
|
||||
"requirements": ["hass-nabucasa==0.54.0"],
|
||||
"requirements": ["hass-nabucasa==0.54.1"],
|
||||
"dependencies": ["http", "webhook"],
|
||||
"after_dependencies": ["google_assistant", "alexa"],
|
||||
"codeowners": ["@home-assistant/cloud"],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "deCONZ",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/deconz",
|
||||
"requirements": ["pydeconz==96"],
|
||||
"requirements": ["pydeconz==98"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Royal Philips Electronics",
|
||||
|
||||
@@ -7,20 +7,24 @@ from pyecobee.const import ECOBEE_STATE_UNKNOWN
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_FORECAST_CONDITION,
|
||||
ATTR_FORECAST_TEMP,
|
||||
ATTR_FORECAST_TEMP_LOW,
|
||||
ATTR_FORECAST_NATIVE_TEMP,
|
||||
ATTR_FORECAST_NATIVE_TEMP_LOW,
|
||||
ATTR_FORECAST_NATIVE_WIND_SPEED,
|
||||
ATTR_FORECAST_TIME,
|
||||
ATTR_FORECAST_WIND_BEARING,
|
||||
ATTR_FORECAST_WIND_SPEED,
|
||||
WeatherEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PRESSURE_HPA, PRESSURE_INHG, TEMP_FAHRENHEIT
|
||||
from homeassistant.const import (
|
||||
LENGTH_METERS,
|
||||
PRESSURE_HPA,
|
||||
SPEED_METERS_PER_SECOND,
|
||||
TEMP_FAHRENHEIT,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.pressure import convert as pressure_convert
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
@@ -49,6 +53,11 @@ async def async_setup_entry(
|
||||
class EcobeeWeather(WeatherEntity):
|
||||
"""Representation of Ecobee weather data."""
|
||||
|
||||
_attr_native_pressure_unit = PRESSURE_HPA
|
||||
_attr_native_temperature_unit = TEMP_FAHRENHEIT
|
||||
_attr_native_visibility_unit = LENGTH_METERS
|
||||
_attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND
|
||||
|
||||
def __init__(self, data, name, index):
|
||||
"""Initialize the Ecobee weather platform."""
|
||||
self.data = data
|
||||
@@ -101,7 +110,7 @@ class EcobeeWeather(WeatherEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def temperature(self):
|
||||
def native_temperature(self):
|
||||
"""Return the temperature."""
|
||||
try:
|
||||
return float(self.get_forecast(0, "temperature")) / 10
|
||||
@@ -109,18 +118,10 @@ class EcobeeWeather(WeatherEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
return TEMP_FAHRENHEIT
|
||||
|
||||
@property
|
||||
def pressure(self):
|
||||
def native_pressure(self):
|
||||
"""Return the pressure."""
|
||||
try:
|
||||
pressure = self.get_forecast(0, "pressure")
|
||||
if not self.hass.config.units.is_metric:
|
||||
pressure = pressure_convert(pressure, PRESSURE_HPA, PRESSURE_INHG)
|
||||
return round(pressure, 2)
|
||||
return round(pressure)
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -134,15 +135,15 @@ class EcobeeWeather(WeatherEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def visibility(self):
|
||||
def native_visibility(self):
|
||||
"""Return the visibility."""
|
||||
try:
|
||||
return int(self.get_forecast(0, "visibility")) / 1000
|
||||
return int(self.get_forecast(0, "visibility"))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def wind_speed(self):
|
||||
def native_wind_speed(self):
|
||||
"""Return the wind speed."""
|
||||
try:
|
||||
return int(self.get_forecast(0, "windSpeed"))
|
||||
@@ -202,13 +203,13 @@ def _process_forecast(json):
|
||||
json["weatherSymbol"]
|
||||
]
|
||||
if json["tempHigh"] != ECOBEE_STATE_UNKNOWN:
|
||||
forecast[ATTR_FORECAST_TEMP] = float(json["tempHigh"]) / 10
|
||||
forecast[ATTR_FORECAST_NATIVE_TEMP] = float(json["tempHigh"]) / 10
|
||||
if json["tempLow"] != ECOBEE_STATE_UNKNOWN:
|
||||
forecast[ATTR_FORECAST_TEMP_LOW] = float(json["tempLow"]) / 10
|
||||
forecast[ATTR_FORECAST_NATIVE_TEMP_LOW] = float(json["tempLow"]) / 10
|
||||
if json["windBearing"] != ECOBEE_STATE_UNKNOWN:
|
||||
forecast[ATTR_FORECAST_WIND_BEARING] = int(json["windBearing"])
|
||||
if json["windSpeed"] != ECOBEE_STATE_UNKNOWN:
|
||||
forecast[ATTR_FORECAST_WIND_SPEED] = int(json["windSpeed"])
|
||||
forecast[ATTR_FORECAST_NATIVE_WIND_SPEED] = int(json["windSpeed"])
|
||||
|
||||
except (ValueError, IndexError, KeyError):
|
||||
return None
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "edl21",
|
||||
"name": "EDL21",
|
||||
"documentation": "https://www.home-assistant.io/integrations/edl21",
|
||||
"requirements": ["pysml==0.0.7"],
|
||||
"requirements": ["pysml==0.0.8"],
|
||||
"codeowners": ["@mtdcr"],
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sml"]
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
DEGREE,
|
||||
ELECTRIC_CURRENT_AMPERE,
|
||||
ELECTRIC_POTENTIAL_VOLT,
|
||||
ENERGY_KILO_WATT_HOUR,
|
||||
@@ -250,6 +251,7 @@ SENSOR_UNIT_MAPPING = {
|
||||
"W": POWER_WATT,
|
||||
"A": ELECTRIC_CURRENT_AMPERE,
|
||||
"V": ELECTRIC_POTENTIAL_VOLT,
|
||||
"°": DEGREE,
|
||||
}
|
||||
|
||||
|
||||
@@ -449,7 +451,7 @@ class EDL21Entity(SensorEntity):
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
if (unit := self._telegram.get("unit")) is None:
|
||||
if (unit := self._telegram.get("unit")) is None or unit == 0:
|
||||
return None
|
||||
|
||||
return SENSOR_UNIT_MAPPING[unit]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Epson",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/epson",
|
||||
"requirements": ["epson-projector==0.4.2"],
|
||||
"requirements": ["epson-projector==0.4.6"],
|
||||
"codeowners": ["@pszafer"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["epson_projector"]
|
||||
|
||||
@@ -133,7 +133,10 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
|
||||
self._source = SOURCE_LIST.get(source, self._source)
|
||||
volume = await self._projector.get_property(VOLUME)
|
||||
if volume:
|
||||
self._volume = volume
|
||||
try:
|
||||
self._volume = float(volume)
|
||||
except ValueError:
|
||||
self._volume = None
|
||||
elif power_state == BUSY:
|
||||
self._state = STATE_ON
|
||||
else:
|
||||
@@ -176,11 +179,13 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
|
||||
"""Turn on epson."""
|
||||
if self._state == STATE_OFF:
|
||||
await self._projector.send_command(TURN_ON)
|
||||
self._state = STATE_ON
|
||||
|
||||
async def async_turn_off(self):
|
||||
"""Turn off epson."""
|
||||
if self._state == STATE_ON:
|
||||
await self._projector.send_command(TURN_OFF)
|
||||
self._state = STATE_OFF
|
||||
|
||||
@property
|
||||
def source_list(self):
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ezviz",
|
||||
"dependencies": ["ffmpeg"],
|
||||
"codeowners": ["@RenierM26", "@baqs"],
|
||||
"requirements": ["pyezviz==0.2.0.8"],
|
||||
"requirements": ["pyezviz==0.2.0.9"],
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["paho_mqtt", "pyezviz"]
|
||||
|
||||
@@ -99,7 +99,7 @@ class ForecastSolarOptionFlowHandler(OptionsFlow):
|
||||
CONF_API_KEY,
|
||||
description={
|
||||
"suggested_value": self.config_entry.options.get(
|
||||
CONF_API_KEY
|
||||
CONF_API_KEY, ""
|
||||
)
|
||||
},
|
||||
): str,
|
||||
|
||||
@@ -113,7 +113,7 @@ class FreeboxRouter:
|
||||
# According to the doc `syst_datas["sensors"]` is temperature sensors in celsius degree.
|
||||
# Name and id of sensors may vary under Freebox devices.
|
||||
for sensor in syst_datas["sensors"]:
|
||||
self.sensors_temperature[sensor["name"]] = sensor["value"]
|
||||
self.sensors_temperature[sensor["name"]] = sensor.get("value")
|
||||
|
||||
# Connection sensors
|
||||
connection_datas: dict[str, Any] = await self._api.connection.get_status()
|
||||
|
||||
@@ -159,7 +159,7 @@ class FreeboxDiskSensor(FreeboxSensor):
|
||||
self._disk = disk
|
||||
self._partition = partition
|
||||
self._attr_name = f"{partition['label']} {description.name}"
|
||||
self._unique_id = f"{self._router.mac} {description.key} {self._disk['id']} {self._partition['id']}"
|
||||
self._attr_unique_id = f"{self._router.mac} {description.key} {self._disk['id']} {self._partition['id']}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "frontend",
|
||||
"name": "Home Assistant Frontend",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"requirements": ["home-assistant-frontend==20220707.0"],
|
||||
"requirements": ["home-assistant-frontend==20220707.1"],
|
||||
"dependencies": [
|
||||
"api",
|
||||
"auth",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "frontier_silicon",
|
||||
"name": "Frontier Silicon",
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontier_silicon",
|
||||
"requirements": ["afsapi==0.2.4"],
|
||||
"requirements": ["afsapi==0.2.6"],
|
||||
"codeowners": ["@wlcrs"],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
@@ -179,11 +179,14 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
self._attr_media_artist = await afsapi.get_play_artist()
|
||||
self._attr_media_album_name = await afsapi.get_play_album()
|
||||
|
||||
self._attr_source = (await afsapi.get_mode()).label
|
||||
radio_mode = await afsapi.get_mode()
|
||||
self._attr_source = radio_mode.label if radio_mode is not None else None
|
||||
|
||||
self._attr_is_volume_muted = await afsapi.get_mute()
|
||||
self._attr_media_image_url = await afsapi.get_play_graphic()
|
||||
self._attr_sound_mode = (await afsapi.get_eq_preset()).label
|
||||
|
||||
eq_preset = await afsapi.get_eq_preset()
|
||||
self._attr_sound_mode = eq_preset.label if eq_preset is not None else None
|
||||
|
||||
volume = await self.fs_device.get_volume()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "hdmi_cec",
|
||||
"name": "HDMI-CEC",
|
||||
"documentation": "https://www.home-assistant.io/integrations/hdmi_cec",
|
||||
"requirements": ["pyCEC==0.5.1"],
|
||||
"requirements": ["pyCEC==0.5.2"],
|
||||
"codeowners": [],
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pycec"]
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
"mode": "Travel Mode"
|
||||
}
|
||||
},
|
||||
"origin_menu": {
|
||||
"title": "Choose Origin",
|
||||
"menu_options": {
|
||||
"origin_coordinates": "Using a map location",
|
||||
"origin_entity": "Using an entity"
|
||||
}
|
||||
},
|
||||
"origin_coordinates": {
|
||||
"title": "Choose Origin",
|
||||
"data": {
|
||||
|
||||
@@ -39,6 +39,13 @@
|
||||
},
|
||||
"title": "Choose Origin"
|
||||
},
|
||||
"origin_menu": {
|
||||
"menu_options": {
|
||||
"origin_coordinates": "Using a map location",
|
||||
"origin_entity": "Using an entity"
|
||||
},
|
||||
"title": "Choose Origin"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "API Key",
|
||||
|
||||
@@ -44,13 +44,15 @@ class HiveDeviceLight(HiveEntity, LightEntity):
|
||||
super().__init__(hive, hive_device)
|
||||
if self.device["hiveType"] == "warmwhitelight":
|
||||
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
self._attr_color_mode = ColorMode.BRIGHTNESS
|
||||
elif self.device["hiveType"] == "tuneablelight":
|
||||
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
elif self.device["hiveType"] == "colourtuneablelight":
|
||||
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS}
|
||||
|
||||
self._attr_min_mireds = self.device.get("min_mireds")
|
||||
self._attr_max_mireds = self.device.get("max_mireds")
|
||||
self._attr_min_mireds = 153
|
||||
self._attr_max_mireds = 370
|
||||
|
||||
@refresh_system
|
||||
async def async_turn_on(self, **kwargs):
|
||||
@@ -94,6 +96,13 @@ class HiveDeviceLight(HiveEntity, LightEntity):
|
||||
if self._attr_available:
|
||||
self._attr_is_on = self.device["status"]["state"]
|
||||
self._attr_brightness = self.device["status"]["brightness"]
|
||||
if self.device["hiveType"] == "tuneablelight":
|
||||
self._attr_color_temp = self.device["status"].get("color_temp")
|
||||
if self.device["hiveType"] == "colourtuneablelight":
|
||||
rgb = self.device["status"]["hs_color"]
|
||||
self._attr_hs_color = color_util.color_RGB_to_hs(*rgb)
|
||||
if self.device["status"]["mode"] == "COLOUR":
|
||||
rgb = self.device["status"]["hs_color"]
|
||||
self._attr_hs_color = color_util.color_RGB_to_hs(*rgb)
|
||||
self._attr_color_mode = ColorMode.HS
|
||||
else:
|
||||
self._attr_color_temp = self.device["status"].get("color_temp")
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, POWER_KILO_WATT
|
||||
from homeassistant.const import PERCENTAGE, POWER_WATT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
@@ -26,7 +26,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="Power",
|
||||
native_unit_of_measurement=POWER_KILO_WATT,
|
||||
native_unit_of_measurement=POWER_WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.components.automation import (
|
||||
)
|
||||
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, KNOWN_DEVICES, TRIGGERS
|
||||
@@ -86,13 +86,13 @@ class TriggerSource:
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach a trigger."""
|
||||
trigger_data = automation_info["trigger_data"]
|
||||
job = HassJob(action)
|
||||
|
||||
@callback
|
||||
def event_handler(char):
|
||||
if config[CONF_SUBTYPE] != HK_TO_HA_INPUT_EVENT_VALUES[char["value"]]:
|
||||
return
|
||||
self._hass.async_create_task(
|
||||
action({"trigger": {**trigger_data, **config}})
|
||||
)
|
||||
self._hass.async_run_hass_job(job, {"trigger": {**trigger_data, **config}})
|
||||
|
||||
trigger = self._triggers[config[CONF_TYPE], config[CONF_SUBTYPE]]
|
||||
iid = trigger["characteristic"]
|
||||
@@ -231,11 +231,11 @@ async def async_setup_triggers_for_entry(hass: HomeAssistant, config_entry):
|
||||
|
||||
def async_fire_triggers(conn: HKDevice, events: dict[tuple[int, int], Any]):
|
||||
"""Process events generated by a HomeKit accessory into automation triggers."""
|
||||
trigger_sources: dict[str, TriggerSource] = conn.hass.data[TRIGGERS]
|
||||
for (aid, iid), ev in events.items():
|
||||
if aid in conn.devices:
|
||||
device_id = conn.devices[aid]
|
||||
if device_id in conn.hass.data[TRIGGERS]:
|
||||
source = conn.hass.data[TRIGGERS][device_id]
|
||||
if source := trigger_sources.get(device_id):
|
||||
source.fire(iid, ev)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HomematicIP Cloud",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
||||
"requirements": ["homematicip==1.0.3"],
|
||||
"requirements": ["homematicip==1.0.4"],
|
||||
"codeowners": [],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "cloud_push",
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.components.weather import (
|
||||
WeatherEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import TEMP_CELSIUS
|
||||
from homeassistant.const import SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
@@ -71,6 +71,9 @@ async def async_setup_entry(
|
||||
class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity):
|
||||
"""Representation of the HomematicIP weather sensor plus & basic."""
|
||||
|
||||
_attr_native_temperature_unit = TEMP_CELSIUS
|
||||
_attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device) -> None:
|
||||
"""Initialize the weather sensor."""
|
||||
super().__init__(hap, device)
|
||||
@@ -81,22 +84,17 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity):
|
||||
return self._device.label
|
||||
|
||||
@property
|
||||
def temperature(self) -> float:
|
||||
def native_temperature(self) -> float:
|
||||
"""Return the platform temperature."""
|
||||
return self._device.actualTemperature
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement."""
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def humidity(self) -> int:
|
||||
"""Return the humidity."""
|
||||
return self._device.humidity
|
||||
|
||||
@property
|
||||
def wind_speed(self) -> float:
|
||||
def native_wind_speed(self) -> float:
|
||||
"""Return the wind speed."""
|
||||
return self._device.windSpeed
|
||||
|
||||
@@ -129,6 +127,9 @@ class HomematicipWeatherSensorPro(HomematicipWeatherSensor):
|
||||
class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity):
|
||||
"""Representation of the HomematicIP home weather."""
|
||||
|
||||
_attr_native_temperature_unit = TEMP_CELSIUS
|
||||
_attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR
|
||||
|
||||
def __init__(self, hap: HomematicipHAP) -> None:
|
||||
"""Initialize the home weather."""
|
||||
hap.home.modelType = "HmIP-Home-Weather"
|
||||
@@ -145,22 +146,17 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity):
|
||||
return f"Weather {self._home.location.city}"
|
||||
|
||||
@property
|
||||
def temperature(self) -> float:
|
||||
def native_temperature(self) -> float:
|
||||
"""Return the temperature."""
|
||||
return self._device.weather.temperature
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement."""
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def humidity(self) -> int:
|
||||
"""Return the humidity."""
|
||||
return self._device.weather.humidity
|
||||
|
||||
@property
|
||||
def wind_speed(self) -> float:
|
||||
def native_wind_speed(self) -> float:
|
||||
"""Return the wind speed."""
|
||||
return round(self._device.weather.windSpeed, 1)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/huawei_lte",
|
||||
"requirements": [
|
||||
"huawei-lte-api==1.6.0",
|
||||
"huawei-lte-api==1.6.1",
|
||||
"stringcase==1.2.0",
|
||||
"url-normalize==1.4.1"
|
||||
],
|
||||
|
||||
@@ -455,14 +455,6 @@ class PowerViewShadeTDBUTop(PowerViewShadeTDBU):
|
||||
super().__init__(coordinator, device_info, room_name, shade, name)
|
||||
self._attr_unique_id = f"{self._shade.id}_top"
|
||||
self._attr_name = f"{self._shade_name} Top"
|
||||
# these shades share a class in parent API
|
||||
# override open position for top shade
|
||||
self._shade.open_position = {
|
||||
ATTR_POSITION1: MIN_POSITION,
|
||||
ATTR_POSITION2: MAX_POSITION,
|
||||
ATTR_POSKIND1: POS_KIND_PRIMARY,
|
||||
ATTR_POSKIND2: POS_KIND_SECONDARY,
|
||||
}
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
@@ -485,6 +477,21 @@ class PowerViewShadeTDBUTop(PowerViewShadeTDBU):
|
||||
# these need to be inverted to report state correctly in HA
|
||||
return hd_position_to_hass(self.positions.secondary, MAX_POSITION)
|
||||
|
||||
@property
|
||||
def open_position(self) -> PowerviewShadeMove:
|
||||
"""Return the open position and required additional positions."""
|
||||
# these shades share a class in parent API
|
||||
# override open position for top shade
|
||||
return PowerviewShadeMove(
|
||||
{
|
||||
ATTR_POSITION1: MIN_POSITION,
|
||||
ATTR_POSITION2: MAX_POSITION,
|
||||
ATTR_POSKIND1: POS_KIND_PRIMARY,
|
||||
ATTR_POSKIND2: POS_KIND_SECONDARY,
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _clamp_cover_limit(self, target_hass_position: int) -> int:
|
||||
"""Dont allow a cover to go into an impossbile position."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "HVV Departures",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/hvv_departures",
|
||||
"requirements": ["pygti==0.9.2"],
|
||||
"requirements": ["pygti==0.9.3"],
|
||||
"codeowners": ["@vigonotion"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pygti"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "ialarm",
|
||||
"name": "Antifurto365 iAlarm",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ialarm",
|
||||
"requirements": ["pyialarm==1.9.0"],
|
||||
"requirements": ["pyialarm==2.2.0"],
|
||||
"codeowners": ["@RyuzakiKK"],
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -86,7 +86,7 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity):
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of measurement."""
|
||||
if self._insteon_device.properties[CELSIUS].value:
|
||||
if self._insteon_device.configuration[CELSIUS].value:
|
||||
return TEMP_CELSIUS
|
||||
return TEMP_FAHRENHEIT
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/insteon",
|
||||
"dependencies": ["http", "websocket_api"],
|
||||
"requirements": [
|
||||
"pyinsteon==1.1.1",
|
||||
"insteon-frontend-home-assistant==0.1.1"
|
||||
"pyinsteon==1.1.3",
|
||||
"insteon-frontend-home-assistant==0.2.0"
|
||||
],
|
||||
"codeowners": ["@teharris1"],
|
||||
"dhcp": [
|
||||
|
||||
@@ -37,7 +37,7 @@ from .const import (
|
||||
SHOW_DRIVING,
|
||||
SHOW_MOVING,
|
||||
)
|
||||
from .coordinator import Life360DataUpdateCoordinator
|
||||
from .coordinator import Life360DataUpdateCoordinator, MissingLocReason
|
||||
|
||||
PLATFORMS = [Platform.DEVICE_TRACKER]
|
||||
|
||||
@@ -128,6 +128,10 @@ class IntegData:
|
||||
coordinators: dict[str, Life360DataUpdateCoordinator] = field(
|
||||
init=False, default_factory=dict
|
||||
)
|
||||
# member_id: missing location reason
|
||||
missing_loc_reason: dict[str, MissingLocReason] = field(
|
||||
init=False, default_factory=dict
|
||||
)
|
||||
# member_id: ConfigEntry.entry_id
|
||||
tracked_members: dict[str, str] = field(init=False, default_factory=dict)
|
||||
logged_circles: list[str] = field(init=False, default_factory=list)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from life360 import Life360, Life360Error, LoginError
|
||||
@@ -33,6 +35,13 @@ from .const import (
|
||||
)
|
||||
|
||||
|
||||
class MissingLocReason(Enum):
|
||||
"""Reason member location information is missing."""
|
||||
|
||||
VAGUE_ERROR_REASON = "vague error reason"
|
||||
EXPLICIT_ERROR_REASON = "explicit error reason"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Life360Place:
|
||||
"""Life360 Place data."""
|
||||
@@ -99,6 +108,7 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator):
|
||||
max_retries=COMM_MAX_RETRIES,
|
||||
authorization=entry.data[CONF_AUTHORIZATION],
|
||||
)
|
||||
self._missing_loc_reason = hass.data[DOMAIN].missing_loc_reason
|
||||
|
||||
async def _retrieve_data(self, func: str, *args: Any) -> list[dict[str, Any]]:
|
||||
"""Get data from Life360."""
|
||||
@@ -141,10 +151,7 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator):
|
||||
if not int(member["features"]["shareLocation"]):
|
||||
continue
|
||||
|
||||
# Note that member may be in more than one circle. If that's the case just
|
||||
# go ahead and process the newly retrieved data (overwriting the older
|
||||
# data), since it might be slightly newer than what was retrieved while
|
||||
# processing another circle.
|
||||
member_id = member["id"]
|
||||
|
||||
first = member["firstName"]
|
||||
last = member["lastName"]
|
||||
@@ -153,16 +160,45 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator):
|
||||
else:
|
||||
name = first or last
|
||||
|
||||
loc = member["location"]
|
||||
if not loc:
|
||||
if err_msg := member["issues"]["title"]:
|
||||
if member["issues"]["dialog"]:
|
||||
err_msg += f": {member['issues']['dialog']}"
|
||||
else:
|
||||
err_msg = "Location information missing"
|
||||
LOGGER.error("%s: %s", name, err_msg)
|
||||
cur_missing_reason = self._missing_loc_reason.get(member_id)
|
||||
|
||||
# Check if location information is missing. This can happen if server
|
||||
# has not heard from member's device in a long time (e.g., has been off
|
||||
# for a long time, or has lost service, etc.)
|
||||
if loc := member["location"]:
|
||||
with suppress(KeyError):
|
||||
del self._missing_loc_reason[member_id]
|
||||
else:
|
||||
if explicit_reason := member["issues"]["title"]:
|
||||
if extended_reason := member["issues"]["dialog"]:
|
||||
explicit_reason += f": {extended_reason}"
|
||||
# Note that different Circles can report missing location in
|
||||
# different ways. E.g., one might report an explicit reason and
|
||||
# another does not. If a vague reason has already been logged but a
|
||||
# more explicit reason is now available, log that, too.
|
||||
if (
|
||||
cur_missing_reason is None
|
||||
or cur_missing_reason == MissingLocReason.VAGUE_ERROR_REASON
|
||||
and explicit_reason
|
||||
):
|
||||
if explicit_reason:
|
||||
self._missing_loc_reason[
|
||||
member_id
|
||||
] = MissingLocReason.EXPLICIT_ERROR_REASON
|
||||
err_msg = explicit_reason
|
||||
else:
|
||||
self._missing_loc_reason[
|
||||
member_id
|
||||
] = MissingLocReason.VAGUE_ERROR_REASON
|
||||
err_msg = "Location information missing"
|
||||
LOGGER.error("%s: %s", name, err_msg)
|
||||
continue
|
||||
|
||||
# Note that member may be in more than one circle. If that's the case
|
||||
# just go ahead and process the newly retrieved data (overwriting the
|
||||
# older data), since it might be slightly newer than what was retrieved
|
||||
# while processing another circle.
|
||||
|
||||
place = loc["name"] or None
|
||||
|
||||
if place:
|
||||
@@ -179,7 +215,7 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator):
|
||||
if self._hass.config.units.is_metric:
|
||||
speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS)
|
||||
|
||||
data.members[member["id"]] = Life360Member(
|
||||
data.members[member_id] = Life360Member(
|
||||
address,
|
||||
dt_util.utc_from_timestamp(int(loc["since"])),
|
||||
bool(int(loc["charge"])),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Litter-Robot",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/litterrobot",
|
||||
"requirements": ["pylitterbot==2022.3.0"],
|
||||
"requirements": ["pylitterbot==2022.7.0"],
|
||||
"codeowners": ["@natekspencer"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pylitterbot"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Mazda Connected Services",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/mazda",
|
||||
"requirements": ["pymazda==0.3.3"],
|
||||
"requirements": ["pymazda==0.3.6"],
|
||||
"codeowners": ["@bdr99"],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "cloud_polling",
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pymonoprice import get_async_monoprice
|
||||
from pymonoprice import get_monoprice
|
||||
from serial import SerialException
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -56,7 +56,7 @@ async def validate_input(hass: core.HomeAssistant, data):
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
try:
|
||||
await get_async_monoprice(data[CONF_PORT], hass.loop)
|
||||
await hass.async_add_executor_job(get_monoprice, data[CONF_PORT])
|
||||
except SerialException as err:
|
||||
_LOGGER.error("Error connecting to Monoprice controller")
|
||||
raise CannotConnect from err
|
||||
|
||||
@@ -99,7 +99,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
client = Client(
|
||||
host=host,
|
||||
port=port,
|
||||
loop=hass.loop,
|
||||
update_interval=scan_interval.total_seconds(),
|
||||
infer_arming_state=infer_arming_state,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "ness_alarm",
|
||||
"name": "Ness Alarm",
|
||||
"documentation": "https://www.home-assistant.io/integrations/ness_alarm",
|
||||
"requirements": ["nessclient==0.9.15"],
|
||||
"requirements": ["nessclient==0.10.0"],
|
||||
"codeowners": ["@nickw444"],
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["nessclient"]
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"dhcp": [
|
||||
{ "macaddress": "18B430*" },
|
||||
{ "macaddress": "641666*" },
|
||||
{ "macaddress": "D8EB46*" },
|
||||
{ "macaddress": "1C53F9*" }
|
||||
{ "macaddress": "D8EB46*" }
|
||||
],
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["google_nest_sdm", "nest"]
|
||||
|
||||
@@ -59,7 +59,9 @@ class NetgearUpdateEntity(NetgearRouterEntity, UpdateEntity):
|
||||
"""Latest version available for install."""
|
||||
if self.coordinator.data is not None:
|
||||
new_version = self.coordinator.data.get("NewVersion")
|
||||
if new_version is not None:
|
||||
if new_version is not None and not new_version.startswith(
|
||||
self.installed_version
|
||||
):
|
||||
return new_version
|
||||
return self.installed_version
|
||||
|
||||
|
||||
@@ -416,7 +416,7 @@ class OpenThermGatewayDevice:
|
||||
self.status = {}
|
||||
self.update_signal = f"{DATA_OPENTHERM_GW}_{self.gw_id}_update"
|
||||
self.options_update_signal = f"{DATA_OPENTHERM_GW}_{self.gw_id}_options_update"
|
||||
self.gateway = pyotgw.pyotgw()
|
||||
self.gateway = pyotgw.OpenThermGateway()
|
||||
self.gw_version = None
|
||||
|
||||
async def cleanup(self, event=None):
|
||||
@@ -427,7 +427,7 @@ class OpenThermGatewayDevice:
|
||||
|
||||
async def connect_and_subscribe(self):
|
||||
"""Connect to serial device and subscribe report handler."""
|
||||
self.status = await self.gateway.connect(self.hass.loop, self.device_path)
|
||||
self.status = await self.gateway.connect(self.device_path)
|
||||
version_string = self.status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT)
|
||||
self.gw_version = version_string[18:] if version_string else None
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -59,8 +59,8 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def test_connection():
|
||||
"""Try to connect to the OpenTherm Gateway."""
|
||||
otgw = pyotgw.pyotgw()
|
||||
status = await otgw.connect(self.hass.loop, device)
|
||||
otgw = pyotgw.OpenThermGateway()
|
||||
status = await otgw.connect(device)
|
||||
await otgw.disconnect()
|
||||
return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "opentherm_gw",
|
||||
"name": "OpenTherm Gateway",
|
||||
"documentation": "https://www.home-assistant.io/integrations/opentherm_gw",
|
||||
"requirements": ["pyotgw==1.1b1"],
|
||||
"requirements": ["pyotgw==2.0.1"],
|
||||
"codeowners": ["@mvn23"],
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiohttp.hdrs import CONTENT_TYPE
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -144,7 +143,7 @@ class PyLoadAPI:
|
||||
"""Initialize pyLoad API and set headers needed later."""
|
||||
self.api_url = api_url
|
||||
self.status = None
|
||||
self.headers = {CONTENT_TYPE: CONTENT_TYPE_JSON}
|
||||
self.headers = {"Content-Type": CONTENT_TYPE_JSON}
|
||||
|
||||
if username is not None and password is not None:
|
||||
self.payload = {"username": username, "password": password}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "QNAP QSW",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/qnap_qsw",
|
||||
"requirements": ["aioqsw==0.1.0"],
|
||||
"requirements": ["aioqsw==0.1.1"],
|
||||
"codeowners": ["@Noltari"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioqsw"],
|
||||
|
||||
@@ -183,7 +183,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry.data[CONF_IP_ADDRESS],
|
||||
entry.data[CONF_PASSWORD],
|
||||
port=entry.data[CONF_PORT],
|
||||
ssl=entry.data.get(CONF_SSL, DEFAULT_SSL),
|
||||
use_ssl=entry.data.get(CONF_SSL, DEFAULT_SSL),
|
||||
)
|
||||
except RainMachineError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
@@ -32,7 +32,7 @@ async def async_get_controller(
|
||||
websession = aiohttp_client.async_get_clientsession(hass)
|
||||
client = Client(session=websession)
|
||||
try:
|
||||
await client.load_local(ip_address, password, port=port, ssl=ssl)
|
||||
await client.load_local(ip_address, password, port=port, use_ssl=ssl)
|
||||
except RainMachineError:
|
||||
return None
|
||||
else:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "RainMachine",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/rainmachine",
|
||||
"requirements": ["regenmaschine==2022.06.1"],
|
||||
"requirements": ["regenmaschine==2022.07.1"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "local_polling",
|
||||
"homekit": {
|
||||
|
||||
@@ -243,10 +243,8 @@ class TimeRemainingSensor(RainMachineEntity, RestoreSensor):
|
||||
seconds_remaining = self.calculate_seconds_remaining()
|
||||
new_timestamp = now + timedelta(seconds=seconds_remaining)
|
||||
|
||||
assert isinstance(self._attr_native_value, datetime)
|
||||
|
||||
if (
|
||||
self._attr_native_value
|
||||
isinstance(self._attr_native_value, datetime)
|
||||
and new_timestamp - self._attr_native_value
|
||||
< DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE
|
||||
):
|
||||
|
||||
@@ -199,7 +199,7 @@ class OptionsFlow(config_entries.OptionsFlow):
|
||||
if not errors:
|
||||
devices = {}
|
||||
device = {
|
||||
CONF_DEVICE_ID: device_id,
|
||||
CONF_DEVICE_ID: list(device_id),
|
||||
}
|
||||
|
||||
devices[self._selected_device_event_code] = device
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "roku",
|
||||
"name": "Roku",
|
||||
"documentation": "https://www.home-assistant.io/integrations/roku",
|
||||
"requirements": ["rokuecp==0.16.0"],
|
||||
"requirements": ["rokuecp==0.17.0"],
|
||||
"homekit": {
|
||||
"models": ["3820X", "3810X", "4660X", "7820X", "C105X", "C135X"]
|
||||
},
|
||||
|
||||
@@ -29,8 +29,7 @@ from .coordinator import RuckusUnleashedDataUpdateCoordinator
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Ruckus Unleashed from a config entry."""
|
||||
try:
|
||||
ruckus = await hass.async_add_executor_job(
|
||||
Ruckus,
|
||||
ruckus = await Ruckus.create(
|
||||
entry.data[CONF_HOST],
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
@@ -42,10 +41,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
system_info = await hass.async_add_executor_job(ruckus.system_info)
|
||||
system_info = await ruckus.system_info()
|
||||
|
||||
registry = device_registry.async_get(hass)
|
||||
ap_info = await hass.async_add_executor_job(ruckus.ap_info)
|
||||
ap_info = await ruckus.ap_info()
|
||||
for device in ap_info[API_AP][API_ID].values():
|
||||
registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
|
||||
@@ -21,22 +21,24 @@ DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def validate_input(hass: core.HomeAssistant, data):
|
||||
async def validate_input(hass: core.HomeAssistant, data):
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
|
||||
try:
|
||||
ruckus = Ruckus(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD])
|
||||
ruckus = await Ruckus.create(
|
||||
data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]
|
||||
)
|
||||
except AuthenticationError as error:
|
||||
raise InvalidAuth from error
|
||||
except ConnectionError as error:
|
||||
raise CannotConnect from error
|
||||
|
||||
mesh_name = ruckus.mesh_name()
|
||||
mesh_name = await ruckus.mesh_name()
|
||||
|
||||
system_info = ruckus.system_info()
|
||||
system_info = await ruckus.system_info()
|
||||
try:
|
||||
host_serial = system_info[API_SYSTEM_OVERVIEW][API_SERIAL]
|
||||
except KeyError as error:
|
||||
@@ -58,9 +60,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await self.hass.async_add_executor_job(
|
||||
validate_input, self.hass, user_input
|
||||
)
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
|
||||
@@ -37,9 +37,7 @@ class RuckusUnleashedDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
async def _fetch_clients(self) -> dict:
|
||||
"""Fetch clients from the API and format them."""
|
||||
clients = await self.hass.async_add_executor_job(
|
||||
self.ruckus.current_active_clients
|
||||
)
|
||||
clients = await self.ruckus.current_active_clients()
|
||||
return {e[API_MAC]: e for e in clients[API_CURRENT_ACTIVE_CLIENTS][API_CLIENTS]}
|
||||
|
||||
async def _async_update_data(self) -> dict:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Ruckus Unleashed",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ruckus_unleashed",
|
||||
"requirements": ["pyruckus==0.12"],
|
||||
"requirements": ["pyruckus==0.16"],
|
||||
"codeowners": ["@gabe565"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pexpect", "pyruckus"]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Shelly",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/shelly",
|
||||
"requirements": ["aioshelly==2.0.0"],
|
||||
"requirements": ["aioshelly==2.0.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
@@ -278,10 +278,8 @@ def _async_standardize_config_entry(hass: HomeAssistant, entry: ConfigEntry) ->
|
||||
"""Bring a config entry up to current standards."""
|
||||
if CONF_TOKEN not in entry.data:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"New SimpliSafe OAuth standard requires re-authentication"
|
||||
"SimpliSafe OAuth standard requires re-authentication"
|
||||
)
|
||||
if CONF_USERNAME not in entry.data:
|
||||
raise ConfigEntryAuthFailed("Need to re-auth with username/password")
|
||||
|
||||
entry_updates = {}
|
||||
if not entry.unique_id:
|
||||
|
||||
@@ -1,48 +1,52 @@
|
||||
"""Config flow to configure the SimpliSafe component."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
import async_timeout
|
||||
from simplipy import API
|
||||
from simplipy.api import AuthStates
|
||||
from simplipy.errors import InvalidCredentialsError, SimplipyError, Verify2FAPending
|
||||
from simplipy.errors import InvalidCredentialsError, SimplipyError
|
||||
from simplipy.util.auth import (
|
||||
get_auth0_code_challenge,
|
||||
get_auth0_code_verifier,
|
||||
get_auth_url,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.const import CONF_CODE, CONF_TOKEN, CONF_URL, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
DEFAULT_EMAIL_2FA_SLEEP = 3
|
||||
DEFAULT_EMAIL_2FA_TIMEOUT = 600
|
||||
|
||||
STEP_REAUTH_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_SMS_2FA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_CODE): cv.string,
|
||||
}
|
||||
)
|
||||
CONF_AUTH_CODE = "auth_code"
|
||||
|
||||
STEP_USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_AUTH_CODE): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SimpliSafeOAuthValues(NamedTuple):
|
||||
"""Define a named tuple to handle SimpliSafe OAuth strings."""
|
||||
|
||||
auth_url: str
|
||||
code_verifier: str
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_simplisafe_oauth_values() -> SimpliSafeOAuthValues:
|
||||
"""Get a SimpliSafe OAuth code verifier and auth URL."""
|
||||
code_verifier = get_auth0_code_verifier()
|
||||
code_challenge = get_auth0_code_challenge(code_verifier)
|
||||
auth_url = get_auth_url(code_challenge)
|
||||
return SimpliSafeOAuthValues(auth_url, code_verifier)
|
||||
|
||||
|
||||
class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a SimpliSafe config flow."""
|
||||
|
||||
@@ -50,45 +54,8 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._email_2fa_task: asyncio.Task | None = None
|
||||
self._password: str | None = None
|
||||
self._oauth_values: SimpliSafeOAuthValues = async_get_simplisafe_oauth_values()
|
||||
self._reauth: bool = False
|
||||
self._simplisafe: API | None = None
|
||||
self._username: str | None = None
|
||||
|
||||
async def _async_authenticate(
|
||||
self, originating_step_id: str, originating_step_schema: vol.Schema
|
||||
) -> FlowResult:
|
||||
"""Attempt to authenticate to the SimpliSafe API."""
|
||||
assert self._password
|
||||
assert self._username
|
||||
|
||||
errors = {}
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
|
||||
try:
|
||||
self._simplisafe = await API.async_from_credentials(
|
||||
self._username, self._password, session=session
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
errors = {"base": "invalid_auth"}
|
||||
except SimplipyError as err:
|
||||
LOGGER.error("Unknown error while logging into SimpliSafe: %s", err)
|
||||
errors = {"base": "unknown"}
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id=originating_step_id,
|
||||
data_schema=originating_step_schema,
|
||||
errors=errors,
|
||||
description_placeholders={CONF_USERNAME: self._username},
|
||||
)
|
||||
|
||||
assert self._simplisafe
|
||||
|
||||
if self._simplisafe.auth_state == AuthStates.PENDING_2FA_SMS:
|
||||
return await self.async_step_sms_2fa()
|
||||
return await self.async_step_email_2fa()
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -98,146 +65,66 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Define the config flow to handle options."""
|
||||
return SimpliSafeOptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult:
|
||||
"""Handle configuration by re-auth."""
|
||||
self._reauth = True
|
||||
|
||||
if CONF_USERNAME not in entry_data:
|
||||
# Old versions of the config flow may not have the username by this point;
|
||||
# in that case, we reauth them by making them go through the user flow:
|
||||
return await self.async_step_user()
|
||||
|
||||
self._username = entry_data[CONF_USERNAME]
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def _async_get_email_2fa(self) -> None:
|
||||
"""Define a task to wait for email-based 2FA."""
|
||||
assert self._simplisafe
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(DEFAULT_EMAIL_2FA_TIMEOUT):
|
||||
while True:
|
||||
try:
|
||||
await self._simplisafe.async_verify_2fa_email()
|
||||
except Verify2FAPending:
|
||||
LOGGER.info("Email-based 2FA pending; trying again")
|
||||
await asyncio.sleep(DEFAULT_EMAIL_2FA_SLEEP)
|
||||
else:
|
||||
break
|
||||
finally:
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.flow.async_configure(flow_id=self.flow_id)
|
||||
)
|
||||
|
||||
async def async_step_email_2fa(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle email-based two-factor authentication."""
|
||||
if not self._email_2fa_task:
|
||||
self._email_2fa_task = self.hass.async_create_task(
|
||||
self._async_get_email_2fa()
|
||||
)
|
||||
return self.async_show_progress(
|
||||
step_id="email_2fa", progress_action="email_2fa"
|
||||
)
|
||||
|
||||
try:
|
||||
await self._email_2fa_task
|
||||
except asyncio.TimeoutError:
|
||||
return self.async_show_progress_done(next_step_id="email_2fa_error")
|
||||
return self.async_show_progress_done(next_step_id="finish")
|
||||
|
||||
async def async_step_email_2fa_error(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle an error during email-based two-factor authentication."""
|
||||
return self.async_abort(reason="email_2fa_timed_out")
|
||||
|
||||
async def async_step_finish(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the final step."""
|
||||
assert self._simplisafe
|
||||
assert self._username
|
||||
|
||||
data = {
|
||||
CONF_USERNAME: self._username,
|
||||
CONF_TOKEN: self._simplisafe.refresh_token,
|
||||
}
|
||||
|
||||
user_id = str(self._simplisafe.user_id)
|
||||
|
||||
if self._reauth:
|
||||
# "Old" config entries utilized the user's email address (username) as the
|
||||
# unique ID, whereas "new" config entries utilize the SimpliSafe user ID –
|
||||
# only one can exist at a time, but the presence of either one is a
|
||||
# candidate for re-auth:
|
||||
if existing_entries := [
|
||||
entry
|
||||
for entry in self.hass.config_entries.async_entries()
|
||||
if entry.domain == DOMAIN
|
||||
and entry.unique_id in (self._username, user_id)
|
||||
]:
|
||||
existing_entry = existing_entries[0]
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry, unique_id=user_id, title=self._username, data=data
|
||||
)
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=self._username, data=data)
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle re-auth completion."""
|
||||
if not user_input:
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_SCHEMA,
|
||||
description_placeholders={CONF_USERNAME: self._username},
|
||||
)
|
||||
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
return await self._async_authenticate("reauth_confirm", STEP_REAUTH_SCHEMA)
|
||||
|
||||
async def async_step_sms_2fa(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle SMS-based two-factor authentication."""
|
||||
if not user_input:
|
||||
return self.async_show_form(
|
||||
step_id="sms_2fa",
|
||||
data_schema=STEP_SMS_2FA_SCHEMA,
|
||||
)
|
||||
|
||||
assert self._simplisafe
|
||||
|
||||
try:
|
||||
await self._simplisafe.async_verify_2fa_sms(user_input[CONF_CODE])
|
||||
except InvalidCredentialsError:
|
||||
return self.async_show_form(
|
||||
step_id="sms_2fa",
|
||||
data_schema=STEP_SMS_2FA_SCHEMA,
|
||||
errors={CONF_CODE: "invalid_auth"},
|
||||
)
|
||||
|
||||
return await self.async_step_finish()
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the start of the config flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user", data_schema=STEP_USER_SCHEMA)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_SCHEMA,
|
||||
description_placeholders={CONF_URL: self._oauth_values.auth_url},
|
||||
)
|
||||
|
||||
self._username = user_input[CONF_USERNAME]
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
return await self._async_authenticate("user", STEP_USER_SCHEMA)
|
||||
errors = {}
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
|
||||
try:
|
||||
simplisafe = await API.async_from_auth(
|
||||
user_input[CONF_AUTH_CODE],
|
||||
self._oauth_values.code_verifier,
|
||||
session=session,
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
errors = {"base": "invalid_auth"}
|
||||
except SimplipyError as err:
|
||||
LOGGER.error("Unknown error while logging into SimpliSafe: %s", err)
|
||||
errors = {"base": "unknown"}
|
||||
|
||||
if errors:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={CONF_URL: self._oauth_values.auth_url},
|
||||
)
|
||||
|
||||
simplisafe_user_id = str(simplisafe.user_id)
|
||||
data = {CONF_USERNAME: simplisafe_user_id, CONF_TOKEN: simplisafe.refresh_token}
|
||||
|
||||
if self._reauth:
|
||||
existing_entry = await self.async_set_unique_id(simplisafe_user_id)
|
||||
if not existing_entry:
|
||||
# If we don't have an entry that matches this user ID, the user logged
|
||||
# in with different credentials:
|
||||
return self.async_abort(reason="wrong_account")
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry, unique_id=simplisafe_user_id, data=data
|
||||
)
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
await self.async_set_unique_id(simplisafe_user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=simplisafe_user_id, data=data)
|
||||
|
||||
|
||||
class SimpliSafeOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_LOCATION
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_CODE, CONF_LOCATION
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import SimpliSafe
|
||||
@@ -23,6 +23,7 @@ CONF_WIFI_SSID = "wifi_ssid"
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_ADDRESS,
|
||||
CONF_CODE,
|
||||
CONF_CREDIT_CARD,
|
||||
CONF_EXPIRES,
|
||||
CONF_LOCATION,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "SimpliSafe",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/simplisafe",
|
||||
"requirements": ["simplisafe-python==2022.06.1"],
|
||||
"requirements": ["simplisafe-python==2022.07.1"],
|
||||
"codeowners": ["@bachya"],
|
||||
"iot_class": "cloud_polling",
|
||||
"dhcp": [
|
||||
|
||||
@@ -1,38 +1,22 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "Please re-enter the password for {username}.",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"sms_2fa": {
|
||||
"description": "Input the two-factor authentication code sent to you via SMS.",
|
||||
"data": {
|
||||
"code": "Code"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"description": "Input your username and password.",
|
||||
"description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. When the process is complete, return here and input the authorization code from the SimpliSafe web app URL.",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
"auth_code": "Authorization Code"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"identifier_exists": "Account already registered",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This SimpliSafe account is already in use.",
|
||||
"email_2fa_timed_out": "Timed out while waiting for email-based two-factor authentication.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"progress": {
|
||||
"email_2fa": "Check your email for a verification link from Simplisafe."
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"wrong_account": "The user credentials provided do not match this SimpliSafe account."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -2,36 +2,20 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This SimpliSafe account is already in use.",
|
||||
"email_2fa_timed_out": "Timed out while waiting for email-based two-factor authentication.",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
"reauth_successful": "Re-authentication was successful",
|
||||
"wrong_account": "The user credentials provided do not match this SimpliSafe account."
|
||||
},
|
||||
"error": {
|
||||
"identifier_exists": "Account already registered",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"progress": {
|
||||
"email_2fa": "Check your email for a verification link from Simplisafe."
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "Password"
|
||||
},
|
||||
"description": "Please re-enter the password for {username}.",
|
||||
"title": "Reauthenticate Integration"
|
||||
},
|
||||
"sms_2fa": {
|
||||
"data": {
|
||||
"code": "Code"
|
||||
},
|
||||
"description": "Input the two-factor authentication code sent to you via SMS."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
"auth_code": "Authorization Code"
|
||||
},
|
||||
"description": "Input your username and password."
|
||||
"description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. When the process is complete, return here and input the authorization code from the SimpliSafe web app URL."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "SMS notifications via GSM-modem",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sms",
|
||||
"requirements": ["python-gammu==3.2.3"],
|
||||
"requirements": ["python-gammu==3.2.4"],
|
||||
"codeowners": ["@ocalvo"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["gammu"]
|
||||
|
||||
@@ -57,9 +57,15 @@ from .const import (
|
||||
SOURCE_TIMEOUT,
|
||||
STREAM_RESTART_INCREMENT,
|
||||
STREAM_RESTART_RESET_TIME,
|
||||
TARGET_SEGMENT_DURATION_NON_LL_HLS,
|
||||
)
|
||||
from .core import PROVIDERS, IdleTimer, KeyFrameConverter, StreamOutput, StreamSettings
|
||||
from .core import (
|
||||
PROVIDERS,
|
||||
STREAM_SETTINGS_NON_LL_HLS,
|
||||
IdleTimer,
|
||||
KeyFrameConverter,
|
||||
StreamOutput,
|
||||
StreamSettings,
|
||||
)
|
||||
from .diagnostics import Diagnostics
|
||||
from .hls import HlsStreamOutput, async_setup_hls
|
||||
|
||||
@@ -181,14 +187,15 @@ def filter_libav_logging() -> None:
|
||||
return logging.getLogger(__name__).isEnabledFor(logging.DEBUG)
|
||||
|
||||
for logging_namespace in (
|
||||
"libav.mp4",
|
||||
"libav.NULL",
|
||||
"libav.h264",
|
||||
"libav.hevc",
|
||||
"libav.hls",
|
||||
"libav.mp4",
|
||||
"libav.mpegts",
|
||||
"libav.rtsp",
|
||||
"libav.tcp",
|
||||
"libav.tls",
|
||||
"libav.mpegts",
|
||||
"libav.NULL",
|
||||
):
|
||||
logging.getLogger(logging_namespace).addFilter(libav_filter)
|
||||
|
||||
@@ -224,14 +231,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hls_part_timeout=2 * conf[CONF_PART_DURATION],
|
||||
)
|
||||
else:
|
||||
hass.data[DOMAIN][ATTR_SETTINGS] = StreamSettings(
|
||||
ll_hls=False,
|
||||
min_segment_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS
|
||||
- SEGMENT_DURATION_ADJUSTER,
|
||||
part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS,
|
||||
hls_advance_part_limit=3,
|
||||
hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS,
|
||||
)
|
||||
hass.data[DOMAIN][ATTR_SETTINGS] = STREAM_SETTINGS_NON_LL_HLS
|
||||
|
||||
# Setup HLS
|
||||
hls_endpoint = async_setup_hls(hass)
|
||||
@@ -503,15 +503,16 @@ class Stream:
|
||||
|
||||
await self.start()
|
||||
|
||||
self._logger.debug("Started a stream recording of %s seconds", duration)
|
||||
|
||||
# Take advantage of lookback
|
||||
hls: HlsStreamOutput = cast(HlsStreamOutput, self.outputs().get(HLS_PROVIDER))
|
||||
if lookback > 0 and hls:
|
||||
num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS)
|
||||
if hls:
|
||||
num_segments = min(int(lookback / hls.target_duration) + 1, MAX_SEGMENTS)
|
||||
# Wait for latest segment, then add the lookback
|
||||
await hls.recv()
|
||||
recorder.prepend(list(hls.get_segments())[-num_segments - 1 : -1])
|
||||
|
||||
self._logger.debug("Started a stream recording of %s seconds", duration)
|
||||
await recorder.async_record()
|
||||
|
||||
async def async_get_image(
|
||||
|
||||
@@ -5,6 +5,7 @@ import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Coroutine, Iterable
|
||||
import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import web
|
||||
@@ -16,13 +17,20 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from .const import ATTR_STREAMS, DOMAIN
|
||||
from .const import (
|
||||
ATTR_STREAMS,
|
||||
DOMAIN,
|
||||
SEGMENT_DURATION_ADJUSTER,
|
||||
TARGET_SEGMENT_DURATION_NON_LL_HLS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from av import CodecContext, Packet
|
||||
|
||||
from . import Stream
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PROVIDERS: Registry[str, type[StreamOutput]] = Registry()
|
||||
|
||||
|
||||
@@ -37,6 +45,15 @@ class StreamSettings:
|
||||
hls_part_timeout: float = attr.ib()
|
||||
|
||||
|
||||
STREAM_SETTINGS_NON_LL_HLS = StreamSettings(
|
||||
ll_hls=False,
|
||||
min_segment_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS - SEGMENT_DURATION_ADJUSTER,
|
||||
part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS,
|
||||
hls_advance_part_limit=3,
|
||||
hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS,
|
||||
)
|
||||
|
||||
|
||||
@attr.s(slots=True)
|
||||
class Part:
|
||||
"""Represent a segment part."""
|
||||
@@ -426,12 +443,22 @@ class KeyFrameConverter:
|
||||
return
|
||||
packet = self.packet
|
||||
self.packet = None
|
||||
# decode packet (flush afterwards)
|
||||
frames = self._codec_context.decode(packet)
|
||||
for _i in range(2):
|
||||
if frames:
|
||||
for _ in range(2): # Retry once if codec context needs to be flushed
|
||||
try:
|
||||
# decode packet (flush afterwards)
|
||||
frames = self._codec_context.decode(packet)
|
||||
for _i in range(2):
|
||||
if frames:
|
||||
break
|
||||
frames = self._codec_context.decode(None)
|
||||
break
|
||||
frames = self._codec_context.decode(None)
|
||||
except EOFError:
|
||||
_LOGGER.debug("Codec context needs flushing, attempting to reopen")
|
||||
self._codec_context.close()
|
||||
self._codec_context.open()
|
||||
else:
|
||||
_LOGGER.debug("Unable to decode keyframe")
|
||||
return
|
||||
if frames:
|
||||
frame = frames[0]
|
||||
if width and height:
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def find_box(
|
||||
@@ -135,3 +139,11 @@ def get_codec_string(mp4_bytes: bytes) -> str:
|
||||
codecs.append(codec)
|
||||
|
||||
return ",".join(codecs)
|
||||
|
||||
|
||||
def read_init(bytes_io: BytesIO) -> bytes:
|
||||
"""Read the init from a mp4 file."""
|
||||
bytes_io.seek(24)
|
||||
moov_len = int.from_bytes(bytes_io.read(4), byteorder="big")
|
||||
bytes_io.seek(0)
|
||||
return bytes_io.read(24 + moov_len)
|
||||
|
||||
@@ -5,11 +5,12 @@ from collections import defaultdict, deque
|
||||
from collections.abc import Callable, Generator, Iterator, Mapping
|
||||
import contextlib
|
||||
import datetime
|
||||
from io import BytesIO
|
||||
from io import SEEK_END, BytesIO
|
||||
import logging
|
||||
from threading import Event
|
||||
from typing import Any, cast
|
||||
|
||||
import attr
|
||||
import av
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -24,8 +25,16 @@ from .const import (
|
||||
SEGMENT_CONTAINER_FORMAT,
|
||||
SOURCE_TIMEOUT,
|
||||
)
|
||||
from .core import KeyFrameConverter, Part, Segment, StreamOutput, StreamSettings
|
||||
from .core import (
|
||||
STREAM_SETTINGS_NON_LL_HLS,
|
||||
KeyFrameConverter,
|
||||
Part,
|
||||
Segment,
|
||||
StreamOutput,
|
||||
StreamSettings,
|
||||
)
|
||||
from .diagnostics import Diagnostics
|
||||
from .fmp4utils import read_init
|
||||
from .hls import HlsStreamOutput
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -108,7 +117,7 @@ class StreamMuxer:
|
||||
hass: HomeAssistant,
|
||||
video_stream: av.video.VideoStream,
|
||||
audio_stream: av.audio.stream.AudioStream | None,
|
||||
audio_bsf: av.BitStreamFilterContext | None,
|
||||
audio_bsf: av.BitStreamFilter | None,
|
||||
stream_state: StreamState,
|
||||
stream_settings: StreamSettings,
|
||||
) -> None:
|
||||
@@ -120,6 +129,7 @@ class StreamMuxer:
|
||||
self._input_video_stream: av.video.VideoStream = video_stream
|
||||
self._input_audio_stream: av.audio.stream.AudioStream | None = audio_stream
|
||||
self._audio_bsf = audio_bsf
|
||||
self._audio_bsf_context: av.BitStreamFilterContext = None
|
||||
self._output_video_stream: av.video.VideoStream = None
|
||||
self._output_audio_stream: av.audio.stream.AudioStream | None = None
|
||||
self._segment: Segment | None = None
|
||||
@@ -151,7 +161,7 @@ class StreamMuxer:
|
||||
**{
|
||||
# Removed skip_sidx - see https://github.com/home-assistant/core/pull/39970
|
||||
# "cmaf" flag replaces several of the movflags used, but too recent to use for now
|
||||
"movflags": "frag_custom+empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer",
|
||||
"movflags": "frag_custom+empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer+delay_moov",
|
||||
# Sometimes the first segment begins with negative timestamps, and this setting just
|
||||
# adjusts the timestamps in the output from that segment to start from 0. Helps from
|
||||
# having to make some adjustments in test_durations
|
||||
@@ -164,7 +174,7 @@ class StreamMuxer:
|
||||
# Fragment durations may exceed the 15% allowed variance but it seems ok
|
||||
**(
|
||||
{
|
||||
"movflags": "empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer",
|
||||
"movflags": "empty_moov+default_base_moof+frag_discont+negative_cts_offsets+skip_trailer+delay_moov",
|
||||
# Create a fragment every TARGET_PART_DURATION. The data from each fragment is stored in
|
||||
# a "Part" that can be combined with the data from all the other "Part"s, plus an init
|
||||
# section, to reconstitute the data in a "Segment".
|
||||
@@ -194,8 +204,11 @@ class StreamMuxer:
|
||||
# Check if audio is requested
|
||||
output_astream = None
|
||||
if input_astream:
|
||||
if self._audio_bsf:
|
||||
self._audio_bsf_context = self._audio_bsf.create()
|
||||
self._audio_bsf_context.set_input_stream(input_astream)
|
||||
output_astream = container.add_stream(
|
||||
template=self._audio_bsf or input_astream
|
||||
template=self._audio_bsf_context or input_astream
|
||||
)
|
||||
return container, output_vstream, output_astream
|
||||
|
||||
@@ -238,15 +251,29 @@ class StreamMuxer:
|
||||
self._part_has_keyframe |= packet.is_keyframe
|
||||
|
||||
elif packet.stream == self._input_audio_stream:
|
||||
if self._audio_bsf:
|
||||
self._audio_bsf.send(packet)
|
||||
while packet := self._audio_bsf.recv():
|
||||
if self._audio_bsf_context:
|
||||
self._audio_bsf_context.send(packet)
|
||||
while packet := self._audio_bsf_context.recv():
|
||||
packet.stream = self._output_audio_stream
|
||||
self._av_output.mux(packet)
|
||||
return
|
||||
packet.stream = self._output_audio_stream
|
||||
self._av_output.mux(packet)
|
||||
|
||||
def create_segment(self) -> None:
|
||||
"""Create a segment when the moov is ready."""
|
||||
self._segment = Segment(
|
||||
sequence=self._stream_state.sequence,
|
||||
stream_id=self._stream_state.stream_id,
|
||||
init=read_init(self._memory_file),
|
||||
# Fetch the latest StreamOutputs, which may have changed since the
|
||||
# worker started.
|
||||
stream_outputs=self._stream_state.outputs,
|
||||
start_time=self._start_time,
|
||||
)
|
||||
self._memory_file_pos = self._memory_file.tell()
|
||||
self._memory_file.seek(0, SEEK_END)
|
||||
|
||||
def check_flush_part(self, packet: av.Packet) -> None:
|
||||
"""Check for and mark a part segment boundary and record its duration."""
|
||||
if self._memory_file_pos == self._memory_file.tell():
|
||||
@@ -254,16 +281,10 @@ class StreamMuxer:
|
||||
if self._segment is None:
|
||||
# We have our first non-zero byte position. This means the init has just
|
||||
# been written. Create a Segment and put it to the queue of each output.
|
||||
self._segment = Segment(
|
||||
sequence=self._stream_state.sequence,
|
||||
stream_id=self._stream_state.stream_id,
|
||||
init=self._memory_file.getvalue(),
|
||||
# Fetch the latest StreamOutputs, which may have changed since the
|
||||
# worker started.
|
||||
stream_outputs=self._stream_state.outputs,
|
||||
start_time=self._start_time,
|
||||
)
|
||||
self._memory_file_pos = self._memory_file.tell()
|
||||
self.create_segment()
|
||||
# When using delay_moov, the moov is not written until a moof is also ready
|
||||
# Flush the moof
|
||||
self.flush(packet, last_part=False)
|
||||
else: # These are the ends of the part segments
|
||||
self.flush(packet, last_part=False)
|
||||
|
||||
@@ -297,6 +318,10 @@ class StreamMuxer:
|
||||
# Closing the av_output will write the remaining buffered data to the
|
||||
# memory_file as a new moof/mdat.
|
||||
self._av_output.close()
|
||||
# With delay_moov, this may be the first time the file pointer has
|
||||
# moved, so the segment may not yet have been created
|
||||
if not self._segment:
|
||||
self.create_segment()
|
||||
elif not self._part_has_keyframe:
|
||||
# Parts which are not the last part or an independent part should
|
||||
# not have durations below 0.85 of the part target duration.
|
||||
@@ -305,6 +330,9 @@ class StreamMuxer:
|
||||
self._part_start_dts
|
||||
+ 0.85 * self._stream_settings.part_target_duration / packet.time_base,
|
||||
)
|
||||
# Undo dts adjustments if we don't have ll_hls
|
||||
if not self._stream_settings.ll_hls:
|
||||
adjusted_dts = packet.dts
|
||||
assert self._segment
|
||||
self._memory_file.seek(self._memory_file_pos)
|
||||
self._hass.loop.call_soon_threadsafe(
|
||||
@@ -445,10 +473,7 @@ def get_audio_bitstream_filter(
|
||||
_LOGGER.debug(
|
||||
"ADTS AAC detected. Adding aac_adtstoaac bitstream filter"
|
||||
)
|
||||
bsf = av.BitStreamFilter("aac_adtstoasc")
|
||||
bsf_context = bsf.create()
|
||||
bsf_context.set_input_stream(audio_stream)
|
||||
return bsf_context
|
||||
return av.BitStreamFilter("aac_adtstoasc")
|
||||
break
|
||||
return None
|
||||
|
||||
@@ -489,7 +514,12 @@ def stream_worker(
|
||||
audio_stream = None
|
||||
# Disable ll-hls for hls inputs
|
||||
if container.format.name == "hls":
|
||||
stream_settings.ll_hls = False
|
||||
for field in attr.fields(StreamSettings):
|
||||
setattr(
|
||||
stream_settings,
|
||||
field.name,
|
||||
getattr(STREAM_SETTINGS_NON_LL_HLS, field.name),
|
||||
)
|
||||
stream_state.diagnostics.set_value("container_format", container.format.name)
|
||||
stream_state.diagnostics.set_value("video_codec", video_stream.name)
|
||||
if audio_stream:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "switchbot",
|
||||
"name": "SwitchBot",
|
||||
"documentation": "https://www.home-assistant.io/integrations/switchbot",
|
||||
"requirements": ["PySwitchbot==0.14.0"],
|
||||
"requirements": ["PySwitchbot==0.14.1"],
|
||||
"config_flow": true,
|
||||
"codeowners": ["@danielhiversen", "@RenierM26"],
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -93,7 +93,7 @@ class SystemBridgeDataUpdateCoordinator(
|
||||
if not self.websocket_client.connected:
|
||||
await self._setup_websocket()
|
||||
|
||||
await self.websocket_client.get_data(modules)
|
||||
self.hass.async_create_task(self.websocket_client.get_data(modules))
|
||||
|
||||
async def async_handle_module(
|
||||
self,
|
||||
@@ -107,9 +107,7 @@ class SystemBridgeDataUpdateCoordinator(
|
||||
|
||||
async def _listen_for_data(self) -> None:
|
||||
"""Listen for events from the WebSocket."""
|
||||
|
||||
try:
|
||||
await self.websocket_client.register_data_listener(MODULES)
|
||||
await self.websocket_client.listen(callback=self.async_handle_module)
|
||||
except AuthenticationException as exception:
|
||||
self.last_update_success = False
|
||||
@@ -175,6 +173,9 @@ class SystemBridgeDataUpdateCoordinator(
|
||||
self.async_update_listeners()
|
||||
|
||||
self.hass.async_create_task(self._listen_for_data())
|
||||
|
||||
await self.websocket_client.register_data_listener(MODULES)
|
||||
|
||||
self.last_update_success = True
|
||||
self.async_update_listeners()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "System Bridge",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/system_bridge",
|
||||
"requirements": ["systembridgeconnector==3.1.5"],
|
||||
"requirements": ["systembridgeconnector==3.3.2"],
|
||||
"codeowners": ["@timmo001"],
|
||||
"zeroconf": ["_system-bridge._tcp.local."],
|
||||
"after_dependencies": ["zeroconf"],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Tomorrow.io",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/tomorrowio",
|
||||
"requirements": ["pytomorrowio==0.3.3"],
|
||||
"requirements": ["pytomorrowio==0.3.4"],
|
||||
"codeowners": ["@raman325", "@lymanepp"],
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class UnifiDeviceScanner(DeviceScanner):
|
||||
def _connect(self):
|
||||
"""Connect to the Unifi AP SSH server."""
|
||||
|
||||
self.ssh = pxssh.pxssh()
|
||||
self.ssh = pxssh.pxssh(options={"HostKeyAlgorithms": "ssh-rsa"})
|
||||
try:
|
||||
self.ssh.login(
|
||||
self.host, self.username, password=self.password, port=self.port
|
||||
|
||||
@@ -72,6 +72,7 @@ class ProtectData:
|
||||
self._pending_camera_ids: set[str] = set()
|
||||
self._unsub_interval: CALLBACK_TYPE | None = None
|
||||
self._unsub_websocket: CALLBACK_TYPE | None = None
|
||||
self._auth_failures = 0
|
||||
|
||||
self.last_update_success = False
|
||||
self.api = protect
|
||||
@@ -117,9 +118,13 @@ class ProtectData:
|
||||
try:
|
||||
updates = await self.api.update(force=force)
|
||||
except NotAuthorized:
|
||||
await self.async_stop()
|
||||
_LOGGER.exception("Reauthentication required")
|
||||
self._entry.async_start_reauth(self._hass)
|
||||
if self._auth_failures < 10:
|
||||
_LOGGER.exception("Auth error while updating")
|
||||
self._auth_failures += 1
|
||||
else:
|
||||
await self.async_stop()
|
||||
_LOGGER.exception("Reauthentication required")
|
||||
self._entry.async_start_reauth(self._hass)
|
||||
self.last_update_success = False
|
||||
except ClientError:
|
||||
if self.last_update_success:
|
||||
@@ -129,6 +134,7 @@ class ProtectData:
|
||||
self._async_process_updates(self.api.bootstrap)
|
||||
else:
|
||||
self.last_update_success = True
|
||||
self._auth_failures = 0
|
||||
self._async_process_updates(updates)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "UniFi Protect",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/unifiprotect",
|
||||
"requirements": ["pyunifiprotect==4.0.9", "unifi-discovery==1.1.4"],
|
||||
"requirements": ["pyunifiprotect==4.0.10", "unifi-discovery==1.1.4"],
|
||||
"dependencies": ["http"],
|
||||
"codeowners": ["@briis", "@AngellusMortis", "@bdraco"],
|
||||
"quality_scale": "platinum",
|
||||
|
||||
@@ -69,11 +69,13 @@ from homeassistant.const import (
|
||||
SERVICE_VOLUME_MUTE,
|
||||
SERVICE_VOLUME_SET,
|
||||
SERVICE_VOLUME_UP,
|
||||
STATE_BUFFERING,
|
||||
STATE_IDLE,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
STATE_STANDBY,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
@@ -101,8 +103,10 @@ STATES_ORDER = [
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_OFF,
|
||||
STATE_IDLE,
|
||||
STATE_STANDBY,
|
||||
STATE_ON,
|
||||
STATE_PAUSED,
|
||||
STATE_BUFFERING,
|
||||
STATE_PLAYING,
|
||||
]
|
||||
ATTRS_SCHEMA = cv.schema_with_slug_keys(cv.string)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Venstar",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/venstar",
|
||||
"requirements": ["venstarcolortouch==0.17"],
|
||||
"requirements": ["venstarcolortouch==0.18"],
|
||||
"codeowners": ["@garbled1"],
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["venstarcolortouch"]
|
||||
|
||||
@@ -83,13 +83,12 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda device: device.details["filter_life"],
|
||||
value_fn=lambda device: device.filter_life,
|
||||
exists_fn=lambda device: sku_supported(device, FILTER_LIFE_SUPPORTED),
|
||||
),
|
||||
VeSyncSensorEntityDescription(
|
||||
key="air-quality",
|
||||
name="Air Quality",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda device: device.details["air_quality"],
|
||||
exists_fn=lambda device: sku_supported(device, AIR_QUALITY_SUPPORTED),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Viessmann ViCare",
|
||||
"documentation": "https://www.home-assistant.io/integrations/vicare",
|
||||
"codeowners": ["@oischinger"],
|
||||
"requirements": ["PyViCare==2.16.2"],
|
||||
"requirements": ["PyViCare==2.16.4"],
|
||||
"iot_class": "cloud_polling",
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
|
||||
@@ -170,7 +170,7 @@ def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice:
|
||||
device_registry = dr.async_get(hass)
|
||||
registry_device = device_registry.async_get(device_id)
|
||||
if not registry_device:
|
||||
raise ValueError(f"Device id `{device_id}` not found in registry.")
|
||||
raise KeyError(f"Device id `{device_id}` not found in registry.")
|
||||
zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
|
||||
ieee_address = list(list(registry_device.identifiers)[0])[1]
|
||||
ieee = zigpy.types.EUI64.convert(ieee_address)
|
||||
|
||||
@@ -73,7 +73,6 @@ CAPABILITIES_COLOR_LOOP = 0x4
|
||||
CAPABILITIES_COLOR_XY = 0x08
|
||||
CAPABILITIES_COLOR_TEMP = 0x10
|
||||
|
||||
DEFAULT_TRANSITION = 1
|
||||
DEFAULT_MIN_BRIGHTNESS = 2
|
||||
|
||||
UPDATE_COLORLOOP_ACTION = 0x1
|
||||
@@ -119,7 +118,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
"""Operations common to all light entities."""
|
||||
|
||||
_FORCE_ON = False
|
||||
_DEFAULT_COLOR_FROM_OFF_TRANSITION = 0
|
||||
_DEFAULT_MIN_TRANSITION_TIME = 0
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the light."""
|
||||
@@ -140,8 +139,8 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
self._level_channel = None
|
||||
self._color_channel = None
|
||||
self._identify_channel = None
|
||||
self._default_transition = None
|
||||
self._color_mode = ColorMode.UNKNOWN # Set by sub classes
|
||||
self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME
|
||||
self._attr_color_mode = ColorMode.UNKNOWN # Set by sub classes
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
@@ -159,11 +158,6 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
return False
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def color_mode(self):
|
||||
"""Return the color mode of this light."""
|
||||
return self._color_mode
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light."""
|
||||
@@ -221,33 +215,49 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
transition = kwargs.get(light.ATTR_TRANSITION)
|
||||
duration = (
|
||||
transition * 10
|
||||
if transition
|
||||
else self._default_transition * 10
|
||||
if self._default_transition
|
||||
else DEFAULT_TRANSITION
|
||||
)
|
||||
if transition is not None
|
||||
else self._zha_config_transition * 10
|
||||
) or self._DEFAULT_MIN_TRANSITION_TIME # if 0 is passed in some devices still need the minimum default
|
||||
brightness = kwargs.get(light.ATTR_BRIGHTNESS)
|
||||
effect = kwargs.get(light.ATTR_EFFECT)
|
||||
flash = kwargs.get(light.ATTR_FLASH)
|
||||
temperature = kwargs.get(light.ATTR_COLOR_TEMP)
|
||||
hs_color = kwargs.get(light.ATTR_HS_COLOR)
|
||||
|
||||
# If the light is currently off but a turn_on call with a color/temperature is sent,
|
||||
# the light needs to be turned on first at a low brightness level where the light is immediately transitioned
|
||||
# to the correct color. Afterwards, the transition is only from the low brightness to the new brightness.
|
||||
# Otherwise, the transition is from the color the light had before being turned on to the new color.
|
||||
# This can look especially bad with transitions longer than a second.
|
||||
color_provided_from_off = (
|
||||
not self._state
|
||||
# This can look especially bad with transitions longer than a second. We do not want to do this for
|
||||
# devices that need to be forced to use the on command because we would end up with 4 commands sent:
|
||||
# move to level, on, color, move to level... We also will not set this if the bulb is already in the
|
||||
# desired color mode with the desired color or color temperature.
|
||||
new_color_provided_while_off = (
|
||||
not isinstance(self, LightGroup)
|
||||
and not self._FORCE_ON
|
||||
and not self._state
|
||||
and (
|
||||
(
|
||||
temperature is not None
|
||||
and (
|
||||
self._color_temp != temperature
|
||||
or self._attr_color_mode != ColorMode.COLOR_TEMP
|
||||
)
|
||||
)
|
||||
or (
|
||||
hs_color is not None
|
||||
and (
|
||||
self.hs_color != hs_color
|
||||
or self._attr_color_mode != ColorMode.HS
|
||||
)
|
||||
)
|
||||
)
|
||||
and brightness_supported(self._attr_supported_color_modes)
|
||||
and (light.ATTR_COLOR_TEMP in kwargs or light.ATTR_HS_COLOR in kwargs)
|
||||
)
|
||||
final_duration = duration
|
||||
if color_provided_from_off:
|
||||
# Set the duration for the color changing commands to 0.
|
||||
duration = 0
|
||||
|
||||
if (
|
||||
brightness is None
|
||||
and (self._off_with_transition or color_provided_from_off)
|
||||
and (self._off_with_transition or new_color_provided_while_off)
|
||||
and self._off_brightness is not None
|
||||
):
|
||||
brightness = self._off_brightness
|
||||
@@ -259,11 +269,11 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
|
||||
t_log = {}
|
||||
|
||||
if color_provided_from_off:
|
||||
if new_color_provided_while_off:
|
||||
# If the light is currently off, we first need to turn it on at a low brightness level with no transition.
|
||||
# After that, we set it to the desired color/temperature with no transition.
|
||||
result = await self._level_channel.move_to_level_with_on_off(
|
||||
DEFAULT_MIN_BRIGHTNESS, self._DEFAULT_COLOR_FROM_OFF_TRANSITION
|
||||
DEFAULT_MIN_BRIGHTNESS, self._DEFAULT_MIN_TRANSITION_TIME
|
||||
)
|
||||
t_log["move_to_level_with_on_off"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
@@ -274,7 +284,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
|
||||
if (
|
||||
(brightness is not None or transition)
|
||||
and not color_provided_from_off
|
||||
and not new_color_provided_while_off
|
||||
and brightness_supported(self._attr_supported_color_modes)
|
||||
):
|
||||
result = await self._level_channel.move_to_level_with_on_off(
|
||||
@@ -290,7 +300,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
|
||||
if (
|
||||
brightness is None
|
||||
and not color_provided_from_off
|
||||
and not new_color_provided_while_off
|
||||
or (self._FORCE_ON and brightness)
|
||||
):
|
||||
# since some lights don't always turn on with move_to_level_with_on_off,
|
||||
@@ -302,34 +312,41 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
return
|
||||
self._state = True
|
||||
|
||||
if light.ATTR_COLOR_TEMP in kwargs:
|
||||
temperature = kwargs[light.ATTR_COLOR_TEMP]
|
||||
result = await self._color_channel.move_to_color_temp(temperature, duration)
|
||||
if temperature is not None:
|
||||
result = await self._color_channel.move_to_color_temp(
|
||||
temperature,
|
||||
self._DEFAULT_MIN_TRANSITION_TIME
|
||||
if new_color_provided_while_off
|
||||
else duration,
|
||||
)
|
||||
t_log["move_to_color_temp"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
self.debug("turned on: %s", t_log)
|
||||
return
|
||||
self._color_mode = ColorMode.COLOR_TEMP
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
self._color_temp = temperature
|
||||
self._hs_color = None
|
||||
|
||||
if light.ATTR_HS_COLOR in kwargs:
|
||||
hs_color = kwargs[light.ATTR_HS_COLOR]
|
||||
if hs_color is not None:
|
||||
xy_color = color_util.color_hs_to_xy(*hs_color)
|
||||
result = await self._color_channel.move_to_color(
|
||||
int(xy_color[0] * 65535), int(xy_color[1] * 65535), duration
|
||||
int(xy_color[0] * 65535),
|
||||
int(xy_color[1] * 65535),
|
||||
self._DEFAULT_MIN_TRANSITION_TIME
|
||||
if new_color_provided_while_off
|
||||
else duration,
|
||||
)
|
||||
t_log["move_to_color"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
self.debug("turned on: %s", t_log)
|
||||
return
|
||||
self._color_mode = ColorMode.HS
|
||||
self._attr_color_mode = ColorMode.HS
|
||||
self._hs_color = hs_color
|
||||
self._color_temp = None
|
||||
|
||||
if color_provided_from_off:
|
||||
if new_color_provided_while_off:
|
||||
# The light is has the correct color, so we can now transition it to the correct brightness level.
|
||||
result = await self._level_channel.move_to_level(level, final_duration)
|
||||
result = await self._level_channel.move_to_level(level, duration)
|
||||
t_log["move_to_level_if_color"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
self.debug("turned on: %s", t_log)
|
||||
@@ -376,12 +393,13 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn the entity off."""
|
||||
duration = kwargs.get(light.ATTR_TRANSITION)
|
||||
transition = kwargs.get(light.ATTR_TRANSITION)
|
||||
supports_level = brightness_supported(self._attr_supported_color_modes)
|
||||
|
||||
if duration and supports_level:
|
||||
# is not none looks odd here but it will override built in bulb transition times if we pass 0 in here
|
||||
if transition is not None and supports_level:
|
||||
result = await self._level_channel.move_to_level_with_on_off(
|
||||
0, duration * 10
|
||||
0, transition * 10
|
||||
)
|
||||
else:
|
||||
result = await self._on_off_channel.off()
|
||||
@@ -392,7 +410,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
|
||||
if supports_level:
|
||||
# store current brightness so that the next turn_on uses it.
|
||||
self._off_with_transition = bool(duration)
|
||||
self._off_with_transition = transition is not None
|
||||
self._off_brightness = self._brightness
|
||||
|
||||
self.async_write_ha_state()
|
||||
@@ -451,13 +469,13 @@ class Light(BaseLight, ZhaEntity):
|
||||
self._attr_supported_color_modes
|
||||
)
|
||||
if len(self._attr_supported_color_modes) == 1:
|
||||
self._color_mode = next(iter(self._attr_supported_color_modes))
|
||||
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
|
||||
else: # Light supports color_temp + hs, determine which mode the light is in
|
||||
assert self._color_channel
|
||||
if self._color_channel.color_mode == Color.ColorMode.Color_temperature:
|
||||
self._color_mode = ColorMode.COLOR_TEMP
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
else:
|
||||
self._color_mode = ColorMode.HS
|
||||
self._attr_color_mode = ColorMode.HS
|
||||
|
||||
if self._identify_channel:
|
||||
self._supported_features |= light.LightEntityFeature.FLASH
|
||||
@@ -465,7 +483,7 @@ class Light(BaseLight, ZhaEntity):
|
||||
if effect_list:
|
||||
self._effect_list = effect_list
|
||||
|
||||
self._default_transition = async_get_zha_config_value(
|
||||
self._zha_config_transition = async_get_zha_config_value(
|
||||
zha_device.gateway.config_entry,
|
||||
ZHA_OPTIONS,
|
||||
CONF_DEFAULT_LIGHT_TRANSITION,
|
||||
@@ -477,6 +495,7 @@ class Light(BaseLight, ZhaEntity):
|
||||
"""Set the state."""
|
||||
self._state = bool(value)
|
||||
if value:
|
||||
self._off_with_transition = False
|
||||
self._off_brightness = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -518,7 +537,7 @@ class Light(BaseLight, ZhaEntity):
|
||||
if "off_brightness" in last_state.attributes:
|
||||
self._off_brightness = last_state.attributes["off_brightness"]
|
||||
if "color_mode" in last_state.attributes:
|
||||
self._color_mode = ColorMode(last_state.attributes["color_mode"])
|
||||
self._attr_color_mode = ColorMode(last_state.attributes["color_mode"])
|
||||
if "color_temp" in last_state.attributes:
|
||||
self._color_temp = last_state.attributes["color_temp"]
|
||||
if "hs_color" in last_state.attributes:
|
||||
@@ -558,13 +577,13 @@ class Light(BaseLight, ZhaEntity):
|
||||
|
||||
if (color_mode := results.get("color_mode")) is not None:
|
||||
if color_mode == Color.ColorMode.Color_temperature:
|
||||
self._color_mode = ColorMode.COLOR_TEMP
|
||||
self._attr_color_mode = ColorMode.COLOR_TEMP
|
||||
color_temp = results.get("color_temperature")
|
||||
if color_temp is not None and color_mode:
|
||||
self._color_temp = color_temp
|
||||
self._hs_color = None
|
||||
else:
|
||||
self._color_mode = ColorMode.HS
|
||||
self._attr_color_mode = ColorMode.HS
|
||||
color_x = results.get("current_x")
|
||||
color_y = results.get("current_y")
|
||||
if color_x is not None and color_y is not None:
|
||||
@@ -610,7 +629,7 @@ class HueLight(Light):
|
||||
@STRICT_MATCH(
|
||||
channel_names=CHANNEL_ON_OFF,
|
||||
aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL},
|
||||
manufacturers={"Jasco", "Quotra-Vision"},
|
||||
manufacturers={"Jasco", "Quotra-Vision", "eWeLight", "eWeLink"},
|
||||
)
|
||||
class ForceOnLight(Light):
|
||||
"""Representation of a light which does not respect move_to_level_with_on_off."""
|
||||
@@ -626,7 +645,7 @@ class ForceOnLight(Light):
|
||||
class SengledLight(Light):
|
||||
"""Representation of a Sengled light which does not react to move_to_color_temp with 0 as a transition."""
|
||||
|
||||
_DEFAULT_COLOR_FROM_OFF_TRANSITION = 1
|
||||
_DEFAULT_MIN_TRANSITION_TIME = 1
|
||||
|
||||
|
||||
@GROUP_MATCH()
|
||||
@@ -644,13 +663,13 @@ class LightGroup(BaseLight, ZhaGroupEntity):
|
||||
self._color_channel = group.endpoint[Color.cluster_id]
|
||||
self._identify_channel = group.endpoint[Identify.cluster_id]
|
||||
self._debounced_member_refresh = None
|
||||
self._default_transition = async_get_zha_config_value(
|
||||
self._zha_config_transition = async_get_zha_config_value(
|
||||
zha_device.gateway.config_entry,
|
||||
ZHA_OPTIONS,
|
||||
CONF_DEFAULT_LIGHT_TRANSITION,
|
||||
0,
|
||||
)
|
||||
self._color_mode = None
|
||||
self._attr_color_mode = None
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Run when about to be added to hass."""
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/zha",
|
||||
"requirements": [
|
||||
"bellows==0.31.0",
|
||||
"bellows==0.31.1",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.6",
|
||||
"zha-quirks==0.0.77",
|
||||
"zigpy-deconz==0.18.0",
|
||||
"zigpy==0.47.2",
|
||||
"zigpy==0.47.3",
|
||||
"zigpy-xbee==0.15.0",
|
||||
"zigpy-zigate==0.9.0",
|
||||
"zigpy-znp==0.8.0"
|
||||
"zigpy-znp==0.8.1"
|
||||
],
|
||||
"usb": [
|
||||
{
|
||||
|
||||
@@ -94,20 +94,23 @@ def get_device_entities(
|
||||
# If the value ID returns as None, we don't need to include this entity
|
||||
if (value_id := get_value_id_from_unique_id(entry.unique_id)) is None:
|
||||
continue
|
||||
state_key = get_state_key_from_unique_id(entry.unique_id)
|
||||
|
||||
zwave_value = node.values[value_id]
|
||||
primary_value_data = {
|
||||
"command_class": zwave_value.command_class,
|
||||
"command_class_name": zwave_value.command_class_name,
|
||||
"endpoint": zwave_value.endpoint,
|
||||
"property": zwave_value.property_,
|
||||
"property_name": zwave_value.property_name,
|
||||
"property_key": zwave_value.property_key,
|
||||
"property_key_name": zwave_value.property_key_name,
|
||||
}
|
||||
if state_key is not None:
|
||||
primary_value_data["state_key"] = state_key
|
||||
primary_value_data = None
|
||||
if (zwave_value := node.values.get(value_id)) is not None:
|
||||
primary_value_data = {
|
||||
"command_class": zwave_value.command_class,
|
||||
"command_class_name": zwave_value.command_class_name,
|
||||
"endpoint": zwave_value.endpoint,
|
||||
"property": zwave_value.property_,
|
||||
"property_name": zwave_value.property_name,
|
||||
"property_key": zwave_value.property_key,
|
||||
"property_key_name": zwave_value.property_key_name,
|
||||
}
|
||||
|
||||
state_key = get_state_key_from_unique_id(entry.unique_id)
|
||||
if state_key is not None:
|
||||
primary_value_data["state_key"] = state_key
|
||||
|
||||
entity = {
|
||||
"domain": entry.domain,
|
||||
"entity_id": entry.entity_id,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user