mirror of
https://github.com/home-assistant/core.git
synced 2026-04-29 18:33:44 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98d5d092a7 | |||
| 1ece39164f |
@@ -58,13 +58,7 @@
|
||||
],
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["homeassistant/components/*/manifest.json"],
|
||||
"url": "./script/json_schemas/manifest_schema.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,7 +1102,7 @@ jobs:
|
||||
./script/check_dirty
|
||||
|
||||
pytest-postgres:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-22.04
|
||||
services:
|
||||
postgres:
|
||||
image: ${{ matrix.postgresql-group }}
|
||||
@@ -1142,9 +1142,7 @@ jobs:
|
||||
sudo apt-get -y install \
|
||||
bluez \
|
||||
ffmpeg \
|
||||
libturbojpeg
|
||||
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
sudo apt-get -y install \
|
||||
libturbojpeg \
|
||||
postgresql-server-dev-14
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
@@ -124,7 +124,6 @@ homeassistant.components.bryant_evolution.*
|
||||
homeassistant.components.bthome.*
|
||||
homeassistant.components.button.*
|
||||
homeassistant.components.calendar.*
|
||||
homeassistant.components.cambridge_audio.*
|
||||
homeassistant.components.camera.*
|
||||
homeassistant.components.canary.*
|
||||
homeassistant.components.cert_expiry.*
|
||||
@@ -209,7 +208,6 @@ homeassistant.components.geo_location.*
|
||||
homeassistant.components.geocaching.*
|
||||
homeassistant.components.gios.*
|
||||
homeassistant.components.glances.*
|
||||
homeassistant.components.go2rtc.*
|
||||
homeassistant.components.goalzero.*
|
||||
homeassistant.components.google.*
|
||||
homeassistant.components.google_assistant_sdk.*
|
||||
|
||||
Vendored
+1
-9
@@ -6,13 +6,5 @@
|
||||
// https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings
|
||||
"python.testing.pytestEnabled": false,
|
||||
// https://code.visualstudio.com/docs/python/linting#_general-settings
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": [
|
||||
"homeassistant/components/*/manifest.json"
|
||||
],
|
||||
"url": "./script/json_schemas/manifest_schema.json"
|
||||
}
|
||||
]
|
||||
"pylint.importStrategy": "fromEnvironment"
|
||||
}
|
||||
|
||||
+2
-8
@@ -617,8 +617,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/hlk_sw16/ @jameshilliard
|
||||
/homeassistant/components/holiday/ @jrieger @gjohansson-ST
|
||||
/tests/components/holiday/ @jrieger @gjohansson-ST
|
||||
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98
|
||||
/tests/components/home_connect/ @DavidMStraub @Diegorro98
|
||||
/homeassistant/components/home_connect/ @DavidMStraub
|
||||
/tests/components/home_connect/ @DavidMStraub
|
||||
/homeassistant/components/homeassistant/ @home-assistant/core
|
||||
/tests/components/homeassistant/ @home-assistant/core
|
||||
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
|
||||
@@ -659,8 +659,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
||||
/homeassistant/components/husqvarna_automower/ @Thomas55555
|
||||
/tests/components/husqvarna_automower/ @Thomas55555
|
||||
/homeassistant/components/husqvarna_automower_ble/ @alistair23
|
||||
/tests/components/husqvarna_automower_ble/ @alistair23
|
||||
/homeassistant/components/huum/ @frwickst
|
||||
/tests/components/huum/ @frwickst
|
||||
/homeassistant/components/hvv_departures/ @vigonotion
|
||||
@@ -821,8 +819,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/lektrico/ @lektrico
|
||||
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/tests/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
/homeassistant/components/lidarr/ @tkdrob
|
||||
/tests/components/lidarr/ @tkdrob
|
||||
/homeassistant/components/lifx/ @Djelibeybi
|
||||
@@ -1093,8 +1089,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
/tests/components/p1_monitor/ @klaasnicolaas
|
||||
/homeassistant/components/palazzetti/ @dotvav
|
||||
/tests/components/palazzetti/ @dotvav
|
||||
/homeassistant/components/panel_custom/ @home-assistant/frontend
|
||||
/tests/components/panel_custom/ @home-assistant/frontend
|
||||
/homeassistant/components/peco/ @IceBotYT
|
||||
|
||||
+3
-4
@@ -7,13 +7,12 @@ FROM ${BUILD_FROM}
|
||||
# Synchronize with homeassistant/core.py:async_stop
|
||||
ENV \
|
||||
S6_SERVICES_GRACETIME=240000 \
|
||||
UV_SYSTEM_PYTHON=true \
|
||||
UV_NO_CACHE=true
|
||||
UV_SYSTEM_PYTHON=true
|
||||
|
||||
ARG QEMU_CPU
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.4.28
|
||||
RUN pip3 install uv==0.4.22
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
@@ -55,7 +54,7 @@ RUN \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.4/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "husqvarna",
|
||||
"name": "Husqvarna",
|
||||
"integrations": ["husqvarna_automower", "husqvarna_automower_ble"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "lg",
|
||||
"name": "LG",
|
||||
"integrations": ["lg_netcast", "lg_soundbar", "lg_thinq", "webostv"]
|
||||
"integrations": ["lg_netcast", "lg_soundbar", "webostv"]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.hassio import HassioServiceInfo
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -17,7 +18,6 @@ from homeassistant.const import (
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ async def async_setup_entry(
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name="Advantage Air",
|
||||
update_method=async_get,
|
||||
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
|
||||
|
||||
@@ -249,7 +249,6 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
name="Rain",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_RAIN_PROB,
|
||||
@@ -264,7 +263,6 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
name="Snow",
|
||||
native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR,
|
||||
device_class=SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_SNOW_PROB,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/agent_dvr",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["agent"],
|
||||
"requirements": ["agent-py==0.0.24"]
|
||||
"requirements": ["agent-py==0.0.23"]
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) ->
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_method=_update_method,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
|
||||
@@ -2,27 +2,75 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
|
||||
from bleak_retry_connector import close_stale_connections_by_address
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import MAX_RETRIES_AFTER_STARTUP
|
||||
from .coordinator import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordinator
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, MAX_RETRIES_AFTER_STARTUP
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AirthingsBLEDataUpdateCoordinator = DataUpdateCoordinator[AirthingsDevice]
|
||||
AirthingsBLEConfigEntry = ConfigEntry[AirthingsBLEDataUpdateCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AirthingsBLEConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Airthings BLE device from a config entry."""
|
||||
coordinator = AirthingsBLEDataUpdateCoordinator(hass, entry)
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
address = entry.unique_id
|
||||
|
||||
is_metric = hass.config.units is METRIC_SYSTEM
|
||||
assert address is not None
|
||||
|
||||
await close_stale_connections_by_address(address)
|
||||
|
||||
ble_device = bluetooth.async_ble_device_from_address(hass, address)
|
||||
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Airthings device with address {address}"
|
||||
)
|
||||
|
||||
airthings = AirthingsBluetoothDeviceData(_LOGGER, is_metric)
|
||||
|
||||
async def _async_update_method() -> AirthingsDevice:
|
||||
"""Get data from Airthings BLE."""
|
||||
try:
|
||||
data = await airthings.update_device(ble_device)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||
|
||||
return data
|
||||
|
||||
coordinator: AirthingsBLEDataUpdateCoordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_method=_async_update_method,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Once its setup and we know we are not going to delay
|
||||
# the startup of Home Assistant, we can set the max attempts
|
||||
# to a higher value. If the first connection attempt fails,
|
||||
# Home Assistant's built-in retry logic will take over.
|
||||
coordinator.airthings.set_max_attempts(MAX_RETRIES_AFTER_STARTUP)
|
||||
airthings.set_max_attempts(MAX_RETRIES_AFTER_STARTUP)
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""The Airthings BLE integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak_retry_connector import close_stale_connections_by_address
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type AirthingsBLEConfigEntry = ConfigEntry[AirthingsBLEDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
||||
"""Class to manage fetching Airthings BLE data."""
|
||||
|
||||
ble_device: BLEDevice
|
||||
config_entry: AirthingsBLEConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: AirthingsBLEConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.airthings = AirthingsBluetoothDeviceData(
|
||||
_LOGGER, hass.config.units is METRIC_SYSTEM
|
||||
)
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
address = self.config_entry.unique_id
|
||||
|
||||
assert address is not None
|
||||
|
||||
await close_stale_connections_by_address(address)
|
||||
|
||||
ble_device = bluetooth.async_ble_device_from_address(self.hass, address)
|
||||
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Airthings device with address {address}"
|
||||
)
|
||||
self.ble_device = ble_device
|
||||
|
||||
async def _async_update_data(self) -> AirthingsDevice:
|
||||
"""Get data from Airthings BLE."""
|
||||
try:
|
||||
data = await self.airthings.update_device(self.ble_device)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||
|
||||
return data
|
||||
@@ -24,5 +24,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airthings_ble",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airthings-ble==0.9.2"]
|
||||
"requirements": ["airthings-ble==0.9.1"]
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from . import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordinator
|
||||
from .const import DOMAIN, VOLUME_BECQUEREL, VOLUME_PICOCURIE
|
||||
from .coordinator import AirthingsBLEConfigEntry, AirthingsBLEDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.COVER]
|
||||
|
||||
type Airtouch5ConfigEntry = ConfigEntry[Airtouch5SimpleClient]
|
||||
@@ -17,6 +19,8 @@ type Airtouch5ConfigEntry = ConfigEntry[Airtouch5SimpleClient]
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: Airtouch5ConfigEntry) -> bool:
|
||||
"""Set up Airtouch 5 from a config entry."""
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
# Create API instance
|
||||
host = entry.data[CONF_HOST]
|
||||
client = Airtouch5SimpleClient(host)
|
||||
|
||||
@@ -204,7 +204,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) ->
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name=async_get_geography_id(entry.data),
|
||||
# We give a placeholder update interval in order to create the coordinator;
|
||||
# then, below, we use the coordinator's presence (along with any other
|
||||
|
||||
@@ -81,7 +81,6 @@ async def async_setup_entry(
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name="Node/Pro data",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
update_method=async_get_data,
|
||||
|
||||
@@ -310,10 +310,6 @@ class AirzoneDeviceClimate(AirzoneClimate):
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
|
||||
if hvac_mode is not None:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
|
||||
params: dict[str, Any] = {}
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
params[API_SETPOINT] = {
|
||||
@@ -337,6 +333,9 @@ class AirzoneDeviceClimate(AirzoneClimate):
|
||||
}
|
||||
await self._async_update_params(params)
|
||||
|
||||
if ATTR_HVAC_MODE in kwargs:
|
||||
await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE])
|
||||
|
||||
|
||||
class AirzoneDeviceGroupClimate(AirzoneClimate):
|
||||
"""Define an Airzone Cloud DeviceGroup base class."""
|
||||
@@ -367,10 +366,6 @@ class AirzoneDeviceGroupClimate(AirzoneClimate):
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
hvac_mode = kwargs.get(ATTR_HVAC_MODE)
|
||||
if hvac_mode is not None:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
|
||||
params: dict[str, Any] = {}
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
params[API_PARAMS] = {
|
||||
@@ -381,6 +376,9 @@ class AirzoneDeviceGroupClimate(AirzoneClimate):
|
||||
}
|
||||
await self._async_update_params(params)
|
||||
|
||||
if ATTR_HVAC_MODE in kwargs:
|
||||
await self.async_set_hvac_mode(kwargs[ATTR_HVAC_MODE])
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set hvac mode."""
|
||||
params: dict[str, Any] = {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.6.10"]
|
||||
"requirements": ["aioairzone-cloud==0.6.8"]
|
||||
}
|
||||
|
||||
@@ -1083,13 +1083,7 @@ async def async_api_arm(
|
||||
arm_state = directive.payload["armState"]
|
||||
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
|
||||
|
||||
# Per Alexa Documentation: users are not allowed to switch from armed_away
|
||||
# directly to another armed state without first disarming the system.
|
||||
# https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-securitypanelcontroller.html#arming
|
||||
if (
|
||||
entity.state == alarm_control_panel.AlarmControlPanelState.ARMED_AWAY
|
||||
and arm_state != "ARMED_AWAY"
|
||||
):
|
||||
if entity.state != alarm_control_panel.AlarmControlPanelState.DISARMED:
|
||||
msg = "You must disarm the system before you can set the requested arm state."
|
||||
raise AlexaSecurityPanelAuthorizationRequired(msg)
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
from homeassistant.loader import (
|
||||
@@ -137,7 +136,7 @@ class Analytics:
|
||||
@property
|
||||
def supervisor(self) -> bool:
|
||||
"""Return bool if a supervisor is present."""
|
||||
return is_hassio(self.hass)
|
||||
return hassio.is_hassio(self.hass)
|
||||
|
||||
async def load(self) -> None:
|
||||
"""Load preferences."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "analytics",
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"after_dependencies": ["energy", "recorder"],
|
||||
"codeowners": ["@home-assistant/core", "@ludeeus"],
|
||||
"dependencies": ["api", "websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
|
||||
@@ -27,7 +27,6 @@ from homeassistant.helpers.selector import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_TRACKED_ADDONS,
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||
CONF_TRACKED_INTEGRATIONS,
|
||||
DOMAIN,
|
||||
@@ -56,12 +55,8 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
if all(
|
||||
[
|
||||
not user_input.get(CONF_TRACKED_ADDONS),
|
||||
not user_input.get(CONF_TRACKED_INTEGRATIONS),
|
||||
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
|
||||
]
|
||||
if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get(
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS
|
||||
):
|
||||
errors["base"] = "no_integrations_selected"
|
||||
else:
|
||||
@@ -69,7 +64,6 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title="Home Assistant Analytics Insights",
|
||||
data={},
|
||||
options={
|
||||
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
|
||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_INTEGRATIONS, []
|
||||
),
|
||||
@@ -83,7 +77,6 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
addons = await client.get_addons()
|
||||
integrations = await client.get_integrations()
|
||||
custom_integrations = await client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError:
|
||||
@@ -106,13 +99,6 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(addons),
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options,
|
||||
@@ -141,19 +127,14 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
if all(
|
||||
[
|
||||
not user_input.get(CONF_TRACKED_ADDONS),
|
||||
not user_input.get(CONF_TRACKED_INTEGRATIONS),
|
||||
not user_input.get(CONF_TRACKED_CUSTOM_INTEGRATIONS),
|
||||
]
|
||||
if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get(
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS
|
||||
):
|
||||
errors["base"] = "no_integrations_selected"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
CONF_TRACKED_ADDONS: user_input.get(CONF_TRACKED_ADDONS, []),
|
||||
CONF_TRACKED_INTEGRATIONS: user_input.get(
|
||||
CONF_TRACKED_INTEGRATIONS, []
|
||||
),
|
||||
@@ -167,7 +148,6 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||
session=async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
addons = await client.get_addons()
|
||||
integrations = await client.get_integrations()
|
||||
custom_integrations = await client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError:
|
||||
@@ -188,13 +168,6 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_TRACKED_ADDONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=list(addons),
|
||||
multiple=True,
|
||||
sort=True,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=options,
|
||||
|
||||
@@ -4,7 +4,6 @@ import logging
|
||||
|
||||
DOMAIN = "analytics_insights"
|
||||
|
||||
CONF_TRACKED_ADDONS = "tracked_addons"
|
||||
CONF_TRACKED_INTEGRATIONS = "tracked_integrations"
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS = "tracked_custom_integrations"
|
||||
|
||||
|
||||
@@ -12,13 +12,11 @@ from python_homeassistant_analytics import (
|
||||
HomeassistantAnalyticsConnectionError,
|
||||
HomeassistantAnalyticsNotModifiedError,
|
||||
)
|
||||
from python_homeassistant_analytics.models import Addon
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONF_TRACKED_ADDONS,
|
||||
CONF_TRACKED_CUSTOM_INTEGRATIONS,
|
||||
CONF_TRACKED_INTEGRATIONS,
|
||||
DOMAIN,
|
||||
@@ -35,7 +33,6 @@ class AnalyticsData:
|
||||
|
||||
active_installations: int
|
||||
reports_integrations: int
|
||||
addons: dict[str, int]
|
||||
core_integrations: dict[str, int]
|
||||
custom_integrations: dict[str, int]
|
||||
|
||||
@@ -56,7 +53,6 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
update_interval=timedelta(hours=12),
|
||||
)
|
||||
self._client = client
|
||||
self._tracked_addons = self.config_entry.options.get(CONF_TRACKED_ADDONS, [])
|
||||
self._tracked_integrations = self.config_entry.options[
|
||||
CONF_TRACKED_INTEGRATIONS
|
||||
]
|
||||
@@ -66,7 +62,6 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
|
||||
async def _async_update_data(self) -> AnalyticsData:
|
||||
try:
|
||||
addons_data = await self._client.get_addons()
|
||||
data = await self._client.get_current_analytics()
|
||||
custom_data = await self._client.get_custom_integrations()
|
||||
except HomeassistantAnalyticsConnectionError as err:
|
||||
@@ -75,9 +70,6 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
) from err
|
||||
except HomeassistantAnalyticsNotModifiedError:
|
||||
return self.data
|
||||
addons = {
|
||||
addon: get_addon_value(addons_data, addon) for addon in self._tracked_addons
|
||||
}
|
||||
core_integrations = {
|
||||
integration: data.integrations.get(integration, 0)
|
||||
for integration in self._tracked_integrations
|
||||
@@ -89,19 +81,11 @@ class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[Analytic
|
||||
return AnalyticsData(
|
||||
data.active_installations,
|
||||
data.reports_integrations,
|
||||
addons,
|
||||
core_integrations,
|
||||
custom_integrations,
|
||||
)
|
||||
|
||||
|
||||
def get_addon_value(data: dict[str, Addon], name_slug: str) -> int:
|
||||
"""Get addon value."""
|
||||
if name_slug in data:
|
||||
return data[name_slug].total
|
||||
return 0
|
||||
|
||||
|
||||
def get_custom_integration_value(
|
||||
data: dict[str, CustomIntegration], domain: str
|
||||
) -> int:
|
||||
|
||||
@@ -29,20 +29,6 @@ class AnalyticsSensorEntityDescription(SensorEntityDescription):
|
||||
value_fn: Callable[[AnalyticsData], StateType]
|
||||
|
||||
|
||||
def get_addon_entity_description(
|
||||
name_slug: str,
|
||||
) -> AnalyticsSensorEntityDescription:
|
||||
"""Get addon entity description."""
|
||||
return AnalyticsSensorEntityDescription(
|
||||
key=f"addon_{name_slug}_active_installations",
|
||||
translation_key="addons",
|
||||
name=name_slug,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
native_unit_of_measurement="active installations",
|
||||
value_fn=lambda data: data.addons.get(name_slug),
|
||||
)
|
||||
|
||||
|
||||
def get_core_integration_entity_description(
|
||||
domain: str, name: str
|
||||
) -> AnalyticsSensorEntityDescription:
|
||||
@@ -103,13 +89,6 @@ async def async_setup_entry(
|
||||
analytics_data.coordinator
|
||||
)
|
||||
entities: list[HomeassistantAnalyticsSensor] = []
|
||||
entities.extend(
|
||||
HomeassistantAnalyticsSensor(
|
||||
coordinator,
|
||||
get_addon_entity_description(addon_name_slug),
|
||||
)
|
||||
for addon_name_slug in coordinator.data.addons
|
||||
)
|
||||
entities.extend(
|
||||
HomeassistantAnalyticsSensor(
|
||||
coordinator,
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"tracked_addons": "Addons",
|
||||
"tracked_integrations": "Integrations",
|
||||
"tracked_custom_integrations": "Custom integrations"
|
||||
},
|
||||
"data_description": {
|
||||
"tracked_addons": "Select the addons you want to track",
|
||||
"tracked_integrations": "Select the integrations you want to track",
|
||||
"tracked_custom_integrations": "Select the custom integrations you want to track"
|
||||
}
|
||||
@@ -26,12 +24,10 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data::tracked_addons%]",
|
||||
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]",
|
||||
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]"
|
||||
},
|
||||
"data_description": {
|
||||
"tracked_addons": "[%key:component::analytics_insights::config::step::user::data_description::tracked_addons%]",
|
||||
"tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]",
|
||||
"tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
@@ -41,7 +40,6 @@ from .const import (
|
||||
CONF_ADB_SERVER_IP,
|
||||
CONF_ADB_SERVER_PORT,
|
||||
CONF_ADBKEY,
|
||||
CONF_SCREENCAP_INTERVAL,
|
||||
CONF_STATE_DETECTION_RULES,
|
||||
DEFAULT_ADB_SERVER_PORT,
|
||||
DEVICE_ANDROIDTV,
|
||||
@@ -68,8 +66,6 @@ RELOAD_OPTIONS = [CONF_STATE_DETECTION_RULES]
|
||||
|
||||
_INVALID_MACS = {"ff:ff:ff:ff:ff:ff"}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AndroidTVRuntimeData:
|
||||
@@ -161,32 +157,6 @@ async def async_connect_androidtv(
|
||||
return aftv, None
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from version %s.%s", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
if entry.version == 1:
|
||||
new_options = {**entry.options}
|
||||
|
||||
# Migrate MinorVersion 1 -> MinorVersion 2: New option
|
||||
if entry.minor_version < 2:
|
||||
new_options = {**new_options, CONF_SCREENCAP_INTERVAL: 0}
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, options=new_options, minor_version=2, version=1
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to configuration version %s.%s successful",
|
||||
entry.version,
|
||||
entry.minor_version,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AndroidTVConfigEntry) -> bool:
|
||||
"""Set up Android Debug Bridge platform."""
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ from .const import (
|
||||
CONF_APPS,
|
||||
CONF_EXCLUDE_UNNAMED_APPS,
|
||||
CONF_GET_SOURCES,
|
||||
CONF_SCREENCAP_INTERVAL,
|
||||
CONF_SCREENCAP,
|
||||
CONF_STATE_DETECTION_RULES,
|
||||
CONF_TURN_OFF_COMMAND,
|
||||
CONF_TURN_ON_COMMAND,
|
||||
@@ -43,7 +43,7 @@ from .const import (
|
||||
DEFAULT_EXCLUDE_UNNAMED_APPS,
|
||||
DEFAULT_GET_SOURCES,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_SCREENCAP_INTERVAL,
|
||||
DEFAULT_SCREENCAP,
|
||||
DEVICE_CLASSES,
|
||||
DOMAIN,
|
||||
PROP_ETHMAC,
|
||||
@@ -76,7 +76,6 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
@callback
|
||||
def _show_setup_form(
|
||||
@@ -254,12 +253,10 @@ class OptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||
CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS
|
||||
),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_SCREENCAP_INTERVAL,
|
||||
default=options.get(
|
||||
CONF_SCREENCAP_INTERVAL, DEFAULT_SCREENCAP_INTERVAL
|
||||
),
|
||||
): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=15)),
|
||||
vol.Optional(
|
||||
CONF_SCREENCAP,
|
||||
default=options.get(CONF_SCREENCAP, DEFAULT_SCREENCAP),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_TURN_OFF_COMMAND,
|
||||
description={
|
||||
|
||||
@@ -9,7 +9,6 @@ CONF_APPS = "apps"
|
||||
CONF_EXCLUDE_UNNAMED_APPS = "exclude_unnamed_apps"
|
||||
CONF_GET_SOURCES = "get_sources"
|
||||
CONF_SCREENCAP = "screencap"
|
||||
CONF_SCREENCAP_INTERVAL = "screencap_interval"
|
||||
CONF_STATE_DETECTION_RULES = "state_detection_rules"
|
||||
CONF_TURN_OFF_COMMAND = "turn_off_command"
|
||||
CONF_TURN_ON_COMMAND = "turn_on_command"
|
||||
@@ -19,7 +18,7 @@ DEFAULT_DEVICE_CLASS = "auto"
|
||||
DEFAULT_EXCLUDE_UNNAMED_APPS = False
|
||||
DEFAULT_GET_SOURCES = True
|
||||
DEFAULT_PORT = 5555
|
||||
DEFAULT_SCREENCAP_INTERVAL = 5
|
||||
DEFAULT_SCREENCAP = True
|
||||
|
||||
DEVICE_ANDROIDTV = "androidtv"
|
||||
DEVICE_FIRETV = "firetv"
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from androidtv.constants import APPS, KEYS
|
||||
from androidtv.setup_async import AndroidTVAsync, FireTVAsync
|
||||
@@ -22,19 +23,19 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import AndroidTVConfigEntry
|
||||
from .const import (
|
||||
CONF_APPS,
|
||||
CONF_EXCLUDE_UNNAMED_APPS,
|
||||
CONF_GET_SOURCES,
|
||||
CONF_SCREENCAP_INTERVAL,
|
||||
CONF_SCREENCAP,
|
||||
CONF_TURN_OFF_COMMAND,
|
||||
CONF_TURN_ON_COMMAND,
|
||||
DEFAULT_EXCLUDE_UNNAMED_APPS,
|
||||
DEFAULT_GET_SOURCES,
|
||||
DEFAULT_SCREENCAP_INTERVAL,
|
||||
DEFAULT_SCREENCAP,
|
||||
DEVICE_ANDROIDTV,
|
||||
SIGNAL_CONFIG_ENTITY,
|
||||
)
|
||||
@@ -47,6 +48,8 @@ ATTR_DEVICE_PATH = "device_path"
|
||||
ATTR_HDMI_INPUT = "hdmi_input"
|
||||
ATTR_LOCAL_PATH = "local_path"
|
||||
|
||||
MIN_TIME_BETWEEN_SCREENCAPS = timedelta(seconds=60)
|
||||
|
||||
SERVICE_ADB_COMMAND = "adb_command"
|
||||
SERVICE_DOWNLOAD = "download"
|
||||
SERVICE_LEARN_SENDEVENT = "learn_sendevent"
|
||||
@@ -122,8 +125,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
self._app_name_to_id: dict[str, str] = {}
|
||||
self._get_sources = DEFAULT_GET_SOURCES
|
||||
self._exclude_unnamed_apps = DEFAULT_EXCLUDE_UNNAMED_APPS
|
||||
self._screencap_delta: timedelta | None = None
|
||||
self._last_screencap: datetime | None = None
|
||||
self._screencap = DEFAULT_SCREENCAP
|
||||
self.turn_on_command: str | None = None
|
||||
self.turn_off_command: str | None = None
|
||||
|
||||
@@ -157,13 +159,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
self._exclude_unnamed_apps = options.get(
|
||||
CONF_EXCLUDE_UNNAMED_APPS, DEFAULT_EXCLUDE_UNNAMED_APPS
|
||||
)
|
||||
screencap_interval: int = options.get(
|
||||
CONF_SCREENCAP_INTERVAL, DEFAULT_SCREENCAP_INTERVAL
|
||||
)
|
||||
if screencap_interval > 0:
|
||||
self._screencap_delta = timedelta(minutes=screencap_interval)
|
||||
else:
|
||||
self._screencap_delta = None
|
||||
self._screencap = options.get(CONF_SCREENCAP, DEFAULT_SCREENCAP)
|
||||
self.turn_off_command = options.get(CONF_TURN_OFF_COMMAND)
|
||||
self.turn_on_command = options.get(CONF_TURN_ON_COMMAND)
|
||||
|
||||
@@ -187,7 +183,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
async def _async_get_screencap(self, prev_app_id: str | None = None) -> None:
|
||||
"""Take a screen capture from the device when enabled."""
|
||||
if (
|
||||
not self._screencap_delta
|
||||
not self._screencap
|
||||
or self.state in {MediaPlayerState.OFF, None}
|
||||
or not self.available
|
||||
):
|
||||
@@ -197,18 +193,11 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
force: bool = prev_app_id is not None
|
||||
if force:
|
||||
force = prev_app_id != self._attr_app_id
|
||||
await self._adb_get_screencap(force)
|
||||
await self._adb_get_screencap(no_throttle=force)
|
||||
|
||||
async def _adb_get_screencap(self, force: bool = False) -> None:
|
||||
"""Take a screen capture from the device every configured minutes."""
|
||||
time_elapsed = self._screencap_delta is not None and (
|
||||
self._last_screencap is None
|
||||
or (utcnow() - self._last_screencap) >= self._screencap_delta
|
||||
)
|
||||
if not (force or time_elapsed):
|
||||
return
|
||||
|
||||
self._last_screencap = utcnow()
|
||||
@Throttle(MIN_TIME_BETWEEN_SCREENCAPS)
|
||||
async def _adb_get_screencap(self, **kwargs: Any) -> None:
|
||||
"""Take a screen capture from the device every 60 seconds."""
|
||||
if media_data := await self._adb_screencap():
|
||||
self._media_image = media_data, "image/png"
|
||||
self._attr_media_image_hash = hashlib.sha256(media_data).hexdigest()[:16]
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"apps": "Configure applications list",
|
||||
"get_sources": "Retrieve the running apps as the list of sources",
|
||||
"exclude_unnamed_apps": "Exclude apps with unknown name from the sources list",
|
||||
"screencap_interval": "Interval in minutes between screen capture for album art (set 0 to disable)",
|
||||
"screencap": "Use screen capture for album art",
|
||||
"state_detection_rules": "Configure state detection rules",
|
||||
"turn_off_command": "ADB shell turn off command (leave empty for default)",
|
||||
"turn_on_command": "ADB shell turn on command (leave empty for default)"
|
||||
|
||||
@@ -15,14 +15,12 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type AranetConfigEntry = ConfigEntry[
|
||||
PassiveBluetoothProcessorCoordinator[Aranet4Advertisement]
|
||||
]
|
||||
|
||||
|
||||
def _service_info_to_adv(
|
||||
service_info: BluetoothServiceInfoBleak,
|
||||
@@ -30,25 +28,30 @@ def _service_info_to_adv(
|
||||
return Aranet4Advertisement(service_info.device, service_info.advertisement)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AranetConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Aranet from a config entry."""
|
||||
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
coordinator = PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.PASSIVE,
|
||||
update_method=_service_info_to_adv,
|
||||
coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
|
||||
PassiveBluetoothProcessorCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.PASSIVE,
|
||||
update_method=_service_info_to_adv,
|
||||
)
|
||||
)
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
# only start after all platforms have had a chance to subscribe
|
||||
entry.async_on_unload(coordinator.async_start())
|
||||
entry.async_on_unload(
|
||||
coordinator.async_start()
|
||||
) # only start after all platforms have had a chance to subscribe
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AranetConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -8,10 +8,12 @@ from typing import Any
|
||||
from aranet4.client import Aranet4Advertisement
|
||||
from bleak.backends.device import BLEDevice
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.bluetooth.passive_update_processor import (
|
||||
PassiveBluetoothDataProcessor,
|
||||
PassiveBluetoothDataUpdate,
|
||||
PassiveBluetoothEntityKey,
|
||||
PassiveBluetoothProcessorCoordinator,
|
||||
PassiveBluetoothProcessorEntity,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -36,8 +38,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import AranetConfigEntry
|
||||
from .const import ARANET_MANUFACTURER_NAME
|
||||
from .const import ARANET_MANUFACTURER_NAME, DOMAIN
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -173,17 +174,20 @@ def sensor_update_to_bluetooth_data_update(
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AranetConfigEntry,
|
||||
entry: config_entries.ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Aranet sensors."""
|
||||
coordinator: PassiveBluetoothProcessorCoordinator[Aranet4Advertisement] = hass.data[
|
||||
DOMAIN
|
||||
][entry.entry_id]
|
||||
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
|
||||
entry.async_on_unload(
|
||||
processor.async_add_entities_listener(
|
||||
Aranet4BluetoothSensorEntity, async_add_entities
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
|
||||
entry.async_on_unload(coordinator.async_register_processor(processor))
|
||||
|
||||
|
||||
class Aranet4BluetoothSensorEntity(
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/autarco",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["autarco==3.1.0"]
|
||||
"requirements": ["autarco==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -124,9 +124,7 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id=STEP_CONN_STRING,
|
||||
data_schema=CONN_STRING_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME]
|
||||
},
|
||||
description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME],
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
@@ -146,9 +144,7 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id=STEP_SAS,
|
||||
data_schema=SAS_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME]
|
||||
},
|
||||
description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME],
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""The Backup integration."""
|
||||
|
||||
from homeassistant.components.hassio import is_hassio
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DATA_MANAGER, DOMAIN, LOGGER
|
||||
|
||||
@@ -7,72 +7,20 @@ from typing import Final
|
||||
|
||||
from mozart_api.models import Source, SourceArray, SourceTypeEnum
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
)
|
||||
from homeassistant.components.media_player import MediaPlayerState, MediaType
|
||||
|
||||
|
||||
class BangOlufsenSource:
|
||||
"""Class used for associating device source ids with friendly names. May not include all sources."""
|
||||
|
||||
URI_STREAMER: Final[Source] = Source(
|
||||
name="Audio Streamer",
|
||||
id="uriStreamer",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
BLUETOOTH: Final[Source] = Source(
|
||||
name="Bluetooth",
|
||||
id="bluetooth",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
CHROMECAST: Final[Source] = Source(
|
||||
name="Chromecast built-in",
|
||||
id="chromeCast",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
LINE_IN: Final[Source] = Source(
|
||||
name="Line-In",
|
||||
id="lineIn",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
SPDIF: Final[Source] = Source(
|
||||
name="Optical",
|
||||
id="spdif",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
NET_RADIO: Final[Source] = Source(
|
||||
name="B&O Radio",
|
||||
id="netRadio",
|
||||
is_seekable=False,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
DEEZER: Final[Source] = Source(
|
||||
name="Deezer",
|
||||
id="deezer",
|
||||
is_seekable=True,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
TIDAL: Final[Source] = Source(
|
||||
name="Tidal",
|
||||
id="tidal",
|
||||
is_seekable=True,
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
)
|
||||
URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer")
|
||||
BLUETOOTH: Final[Source] = Source(name="Bluetooth", id="bluetooth")
|
||||
CHROMECAST: Final[Source] = Source(name="Chromecast built-in", id="chromeCast")
|
||||
LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn")
|
||||
SPDIF: Final[Source] = Source(name="Optical", id="spdif")
|
||||
NET_RADIO: Final[Source] = Source(name="B&O Radio", id="netRadio")
|
||||
DEEZER: Final[Source] = Source(name="Deezer", id="deezer")
|
||||
TIDAL: Final[Source] = Source(name="Tidal", id="tidal")
|
||||
|
||||
|
||||
BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
|
||||
@@ -88,17 +36,6 @@ BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = {
|
||||
"unknown": MediaPlayerState.IDLE,
|
||||
}
|
||||
|
||||
# Dict used for translating Home Assistant settings to device repeat settings.
|
||||
BANG_OLUFSEN_REPEAT_FROM_HA: dict[RepeatMode, str] = {
|
||||
RepeatMode.ALL: "all",
|
||||
RepeatMode.ONE: "track",
|
||||
RepeatMode.OFF: "none",
|
||||
}
|
||||
# Dict used for translating device repeat settings to Home Assistant settings.
|
||||
BANG_OLUFSEN_REPEAT_TO_HA: dict[str, RepeatMode] = {
|
||||
value: key for key, value in BANG_OLUFSEN_REPEAT_FROM_HA.items()
|
||||
}
|
||||
|
||||
|
||||
# Media types for play_media
|
||||
class BangOlufsenMediaType(StrEnum):
|
||||
@@ -186,6 +123,20 @@ VALID_MEDIA_TYPES: Final[tuple] = (
|
||||
MediaType.CHANNEL,
|
||||
)
|
||||
|
||||
# Sources on the device that should not be selectable by the user
|
||||
HIDDEN_SOURCE_IDS: Final[tuple] = (
|
||||
"airPlay",
|
||||
"bluetooth",
|
||||
"chromeCast",
|
||||
"generator",
|
||||
"local",
|
||||
"dlna",
|
||||
"qplay",
|
||||
"wpl",
|
||||
"pl",
|
||||
"beolink",
|
||||
"usbIn",
|
||||
)
|
||||
|
||||
# Fallback sources to use in case of API failure.
|
||||
FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
@@ -193,26 +144,23 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
Source(
|
||||
id="uriStreamer",
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
is_playable=False,
|
||||
name="Audio Streamer",
|
||||
type=SourceTypeEnum(value="uriStreamer"),
|
||||
is_seekable=False,
|
||||
),
|
||||
Source(
|
||||
id="bluetooth",
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
is_playable=False,
|
||||
name="Bluetooth",
|
||||
type=SourceTypeEnum(value="bluetooth"),
|
||||
is_seekable=False,
|
||||
),
|
||||
Source(
|
||||
id="spotify",
|
||||
is_enabled=True,
|
||||
is_playable=True,
|
||||
is_playable=False,
|
||||
name="Spotify Connect",
|
||||
type=SourceTypeEnum(value="spotify"),
|
||||
is_seekable=True,
|
||||
),
|
||||
Source(
|
||||
id="lineIn",
|
||||
@@ -220,7 +168,6 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
is_playable=True,
|
||||
name="Line-In",
|
||||
type=SourceTypeEnum(value="lineIn"),
|
||||
is_seekable=False,
|
||||
),
|
||||
Source(
|
||||
id="spdif",
|
||||
@@ -228,7 +175,6 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
is_playable=True,
|
||||
name="Optical",
|
||||
type=SourceTypeEnum(value="spdif"),
|
||||
is_seekable=False,
|
||||
),
|
||||
Source(
|
||||
id="netRadio",
|
||||
@@ -236,7 +182,6 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
is_playable=True,
|
||||
name="B&O Radio",
|
||||
type=SourceTypeEnum(value="netRadio"),
|
||||
is_seekable=False,
|
||||
),
|
||||
Source(
|
||||
id="deezer",
|
||||
@@ -244,7 +189,6 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
is_playable=True,
|
||||
name="Deezer",
|
||||
type=SourceTypeEnum(value="deezer"),
|
||||
is_seekable=True,
|
||||
),
|
||||
Source(
|
||||
id="tidalConnect",
|
||||
@@ -252,7 +196,6 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray(
|
||||
is_playable=True,
|
||||
name="Tidal Connect",
|
||||
type=SourceTypeEnum(value="tidalConnect"),
|
||||
is_seekable=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import contextlib
|
||||
from datetime import timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from aiohttp import ClientConnectorError
|
||||
from mozart_api import __version__ as MOZART_API_VERSION
|
||||
from mozart_api.exceptions import ApiException
|
||||
from mozart_api.models import (
|
||||
@@ -25,7 +22,6 @@ from mozart_api.models import (
|
||||
PlaybackProgress,
|
||||
PlayQueueItem,
|
||||
PlayQueueItemType,
|
||||
PlayQueueSettings,
|
||||
RenderingState,
|
||||
SceneProperties,
|
||||
SoftwareUpdateState,
|
||||
@@ -48,7 +44,6 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -63,13 +58,12 @@ from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import BangOlufsenConfigEntry
|
||||
from .const import (
|
||||
BANG_OLUFSEN_REPEAT_FROM_HA,
|
||||
BANG_OLUFSEN_REPEAT_TO_HA,
|
||||
BANG_OLUFSEN_STATES,
|
||||
CONF_BEOLINK_JID,
|
||||
CONNECTION_STATUS,
|
||||
DOMAIN,
|
||||
FALLBACK_SOURCES,
|
||||
HIDDEN_SOURCE_IDS,
|
||||
VALID_MEDIA_TYPES,
|
||||
BangOlufsenMediaType,
|
||||
BangOlufsenSource,
|
||||
@@ -78,8 +72,6 @@ from .const import (
|
||||
from .entity import BangOlufsenEntity
|
||||
from .util import get_serial_number_from_jid
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
BANG_OLUFSEN_FEATURES = (
|
||||
@@ -92,9 +84,8 @@ BANG_OLUFSEN_FEATURES = (
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
| MediaPlayerEntityFeature.REPEAT_SET
|
||||
| MediaPlayerEntityFeature.SEEK
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.SHUFFLE_SET
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
@@ -123,6 +114,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
_attr_icon = "mdi:speaker-wireless"
|
||||
_attr_name = None
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_supported_features = BANG_OLUFSEN_FEATURES
|
||||
|
||||
def __init__(self, entry: ConfigEntry, client: MozartClient) -> None:
|
||||
"""Initialize the media player."""
|
||||
@@ -139,7 +131,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
serial_number=self._unique_id,
|
||||
)
|
||||
self._attr_unique_id = self._unique_id
|
||||
self._attr_should_poll = True
|
||||
|
||||
# Misc. variables.
|
||||
self._audio_sources: dict[str, str] = {}
|
||||
@@ -168,7 +159,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error,
|
||||
WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink,
|
||||
WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress,
|
||||
WebsocketNotification.PLAYBACK_SOURCE: self._async_update_sources,
|
||||
WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state,
|
||||
WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources,
|
||||
WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change,
|
||||
@@ -230,20 +220,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
await self._async_update_sound_modes()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update queue settings."""
|
||||
# The WebSocket event listener is the main handler for connection state.
|
||||
# The polling updates do therefore not set the device as available or unavailable
|
||||
with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError):
|
||||
queue_settings = await self._client.get_settings_queue(_request_timeout=5)
|
||||
|
||||
if queue_settings.repeat is not None:
|
||||
self._attr_repeat = BANG_OLUFSEN_REPEAT_TO_HA[queue_settings.repeat]
|
||||
|
||||
if queue_settings.shuffle is not None:
|
||||
self._attr_shuffle = queue_settings.shuffle
|
||||
|
||||
async def _async_update_sources(self, _: Source | None = None) -> None:
|
||||
async def _async_update_sources(self) -> None:
|
||||
"""Get sources for the specific product."""
|
||||
|
||||
# Audio sources
|
||||
@@ -270,7 +247,10 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
self._audio_sources = {
|
||||
source.id: source.name
|
||||
for source in cast(list[Source], sources.items)
|
||||
if source.is_enabled and source.id and source.name and source.is_playable
|
||||
if source.is_enabled
|
||||
and source.id
|
||||
and source.name
|
||||
and source.id not in HIDDEN_SOURCE_IDS
|
||||
}
|
||||
|
||||
# Some sources are not Beolink expandable, meaning that they can't be joined by
|
||||
@@ -484,17 +464,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||
"""Flag media player features that are supported."""
|
||||
features = BANG_OLUFSEN_FEATURES
|
||||
|
||||
# Add seeking if supported by the current source
|
||||
if self._source_change.is_seekable is True:
|
||||
features |= MediaPlayerEntityFeature.SEEK
|
||||
|
||||
return features
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""Return the current state of the media player."""
|
||||
@@ -641,12 +610,17 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Seek to position in ms."""
|
||||
await self._client.seek_to_position(position_ms=int(position * 1000))
|
||||
# Try to prevent the playback progress from bouncing in the UI.
|
||||
self._attr_media_position_updated_at = utcnow()
|
||||
self._playback_progress = PlaybackProgress(progress=int(position))
|
||||
if self._source_change.id == BangOlufsenSource.DEEZER.id:
|
||||
await self._client.seek_to_position(position_ms=int(position * 1000))
|
||||
# Try to prevent the playback progress from bouncing in the UI.
|
||||
self._attr_media_position_updated_at = utcnow()
|
||||
self._playback_progress = PlaybackProgress(progress=int(position))
|
||||
|
||||
self.async_write_ha_state()
|
||||
self.async_write_ha_state()
|
||||
else:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="non_deezer_seeking"
|
||||
)
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send the previous track command."""
|
||||
@@ -656,20 +630,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity):
|
||||
"""Clear the current playback queue."""
|
||||
await self._client.post_clear_queue()
|
||||
|
||||
async def async_set_repeat(self, repeat: RepeatMode) -> None:
|
||||
"""Set playback queues to repeat."""
|
||||
await self._client.set_settings_queue(
|
||||
play_queue_settings=PlayQueueSettings(
|
||||
repeat=BANG_OLUFSEN_REPEAT_FROM_HA[repeat]
|
||||
)
|
||||
)
|
||||
|
||||
async def async_set_shuffle(self, shuffle: bool) -> None:
|
||||
"""Set playback queues to shuffle."""
|
||||
await self._client.set_settings_queue(
|
||||
play_queue_settings=PlayQueueSettings(shuffle=shuffle),
|
||||
)
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select an input source."""
|
||||
if source not in self._sources.values():
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
"m3u_invalid_format": {
|
||||
"message": "Media sources with the .m3u extension are not supported."
|
||||
},
|
||||
"non_deezer_seeking": {
|
||||
"message": "Seeking is currently only supported when using Deezer"
|
||||
},
|
||||
"invalid_source": {
|
||||
"message": "Invalid source: {invalid_source}. Valid sources are: {valid_sources}"
|
||||
},
|
||||
|
||||
@@ -63,9 +63,6 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
self._client.get_playback_progress_notifications(
|
||||
self.on_playback_progress_notification
|
||||
)
|
||||
self._client.get_playback_source_notifications(
|
||||
self.on_playback_source_notification
|
||||
)
|
||||
self._client.get_playback_state_notifications(
|
||||
self.on_playback_state_notification
|
||||
)
|
||||
@@ -160,14 +157,6 @@ class BangOlufsenWebsocket(BangOlufsenBase):
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_playback_source_notification(self, notification: Source) -> None:
|
||||
"""Send playback_source dispatch."""
|
||||
async_dispatcher_send(
|
||||
self.hass,
|
||||
f"{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}",
|
||||
notification,
|
||||
)
|
||||
|
||||
def on_source_change_notification(self, notification: Source) -> None:
|
||||
"""Send source_change dispatch."""
|
||||
async_dispatcher_send(
|
||||
|
||||
@@ -10,6 +10,7 @@ from blinkpy.blinkpy import Blink
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.config_entries import SOURCE_REAUTH
|
||||
from homeassistant.const import (
|
||||
CONF_FILE_PATH,
|
||||
CONF_FILENAME,
|
||||
@@ -40,11 +41,13 @@ SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema(
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def _reauth_flow_wrapper(
|
||||
hass: HomeAssistant, entry: BlinkConfigEntry, data: dict[str, Any]
|
||||
) -> None:
|
||||
async def _reauth_flow_wrapper(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
"""Reauth flow wrapper."""
|
||||
entry.async_start_reauth(hass, data=data)
|
||||
hass.add_job(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_REAUTH}, data=data
|
||||
)
|
||||
)
|
||||
persistent_notification.async_create(
|
||||
hass,
|
||||
(
|
||||
@@ -61,10 +64,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BlinkConfigEntry) -> b
|
||||
data = {**entry.data}
|
||||
if entry.version == 1:
|
||||
data.pop("login_response", None)
|
||||
await _reauth_flow_wrapper(hass, entry, data)
|
||||
await _reauth_flow_wrapper(hass, data)
|
||||
return False
|
||||
if entry.version == 2:
|
||||
await _reauth_flow_wrapper(hass, entry, data)
|
||||
await _reauth_flow_wrapper(hass, data)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -364,13 +364,12 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
if self.is_grouped and not self.is_master:
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
match self._status.state:
|
||||
case "pause":
|
||||
return MediaPlayerState.PAUSED
|
||||
case "stream" | "play":
|
||||
return MediaPlayerState.PLAYING
|
||||
case _:
|
||||
return MediaPlayerState.IDLE
|
||||
status = self._status.state
|
||||
if status in ("pause", "stop"):
|
||||
return MediaPlayerState.PAUSED
|
||||
if status in ("stream", "play"):
|
||||
return MediaPlayerState.PLAYING
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
@@ -770,7 +769,7 @@ class BluesoundPlayer(MediaPlayerEntity):
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Send volume_up command to media player."""
|
||||
volume = int(round(volume * 100))
|
||||
volume = int(volume * 100)
|
||||
volume = min(100, volume)
|
||||
volume = max(0, volume)
|
||||
|
||||
|
||||
@@ -7,11 +7,7 @@ from typing import Any
|
||||
|
||||
from bimmer_connected.api.authentication import MyBMWAuthentication
|
||||
from bimmer_connected.api.regions import get_region_from_name
|
||||
from bimmer_connected.models import (
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError
|
||||
from httpx import RequestError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -58,8 +54,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
|
||||
try:
|
||||
await auth.login()
|
||||
except MyBMWCaptchaMissingError as ex:
|
||||
raise MissingCaptcha from ex
|
||||
except MyBMWAuthError as ex:
|
||||
raise InvalidAuth from ex
|
||||
except (MyBMWAPIError, RequestError) as ex:
|
||||
@@ -104,8 +98,6 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN),
|
||||
CONF_GCID: info.get(CONF_GCID),
|
||||
}
|
||||
except MissingCaptcha:
|
||||
errors["base"] = "missing_captcha"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
@@ -200,7 +192,3 @@ class CannotConnect(HomeAssistantError):
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
|
||||
|
||||
class MissingCaptcha(HomeAssistantError):
|
||||
"""Error to indicate the captcha token is missing."""
|
||||
|
||||
@@ -7,12 +7,7 @@ import logging
|
||||
|
||||
from bimmer_connected.account import MyBMWAccount
|
||||
from bimmer_connected.api.regions import get_region_from_name
|
||||
from bimmer_connected.models import (
|
||||
GPSPosition,
|
||||
MyBMWAPIError,
|
||||
MyBMWAuthError,
|
||||
MyBMWCaptchaMissingError,
|
||||
)
|
||||
from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError
|
||||
from httpx import RequestError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -66,12 +61,6 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
try:
|
||||
await self.account.get_vehicles()
|
||||
except MyBMWCaptchaMissingError as err:
|
||||
# If a captcha is required (user/password login flow), always trigger the reauth flow
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_captcha",
|
||||
) from err
|
||||
except MyBMWAuthError as err:
|
||||
# Allow one retry interval before raising AuthFailed to avoid flaky API issues
|
||||
if self.last_update_success:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["bimmer-connected[china]==0.16.4"]
|
||||
"requirements": ["bimmer-connected[china]==0.16.3"]
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_captcha": "Captcha validation missing"
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
@@ -201,9 +200,6 @@
|
||||
"exceptions": {
|
||||
"invalid_poi": {
|
||||
"message": "Invalid data for point of interest: {poi_exception}"
|
||||
},
|
||||
"missing_captcha": {
|
||||
"message": "Login requires captcha validation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,21 +39,16 @@ HOST_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def write_tls_asset(
|
||||
hass: HomeAssistant, folder: str, filename: str, asset: bytes
|
||||
) -> None:
|
||||
def write_tls_asset(hass: HomeAssistant, filename: str, asset: bytes) -> None:
|
||||
"""Write the tls assets to disk."""
|
||||
makedirs(hass.config.path(DOMAIN, folder), exist_ok=True)
|
||||
with open(
|
||||
hass.config.path(DOMAIN, folder, filename), "w", encoding="utf8"
|
||||
) as file_handle:
|
||||
makedirs(hass.config.path(DOMAIN), exist_ok=True)
|
||||
with open(hass.config.path(DOMAIN, filename), "w", encoding="utf8") as file_handle:
|
||||
file_handle.write(asset.decode("utf-8"))
|
||||
|
||||
|
||||
def create_credentials_and_validate(
|
||||
hass: HomeAssistant,
|
||||
host: str,
|
||||
unique_id: str,
|
||||
user_input: dict[str, Any],
|
||||
zeroconf_instance: zeroconf.HaZeroconf,
|
||||
) -> dict[str, Any] | None:
|
||||
@@ -62,15 +57,13 @@ def create_credentials_and_validate(
|
||||
result = helper.register(host, "HomeAssistant")
|
||||
|
||||
if result is not None:
|
||||
# Save key/certificate pair for each registered host separately
|
||||
# otherwise only the last registered host is accessible.
|
||||
write_tls_asset(hass, unique_id, CONF_SHC_CERT, result["cert"])
|
||||
write_tls_asset(hass, unique_id, CONF_SHC_KEY, result["key"])
|
||||
write_tls_asset(hass, CONF_SHC_CERT, result["cert"])
|
||||
write_tls_asset(hass, CONF_SHC_KEY, result["key"])
|
||||
|
||||
session = SHCSession(
|
||||
host,
|
||||
hass.config.path(DOMAIN, unique_id, CONF_SHC_CERT),
|
||||
hass.config.path(DOMAIN, unique_id, CONF_SHC_KEY),
|
||||
hass.config.path(DOMAIN, CONF_SHC_CERT),
|
||||
hass.config.path(DOMAIN, CONF_SHC_KEY),
|
||||
True,
|
||||
zeroconf_instance,
|
||||
)
|
||||
@@ -150,16 +143,11 @@ class BoschSHCConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
|
||||
# unique_id uniquely identifies the registered controller and is used
|
||||
# to save the key/certificate pair for each controller separately
|
||||
unique_id = self.info["unique_id"]
|
||||
assert unique_id
|
||||
try:
|
||||
result = await self.hass.async_add_executor_job(
|
||||
create_credentials_and_validate,
|
||||
self.hass,
|
||||
self.host,
|
||||
unique_id,
|
||||
user_input,
|
||||
zeroconf_instance,
|
||||
)
|
||||
@@ -179,18 +167,13 @@ class BoschSHCConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
assert result
|
||||
entry_data = {
|
||||
# Each host has its own key/certificate pair
|
||||
CONF_SSL_CERTIFICATE: self.hass.config.path(
|
||||
DOMAIN, unique_id, CONF_SHC_CERT
|
||||
),
|
||||
CONF_SSL_KEY: self.hass.config.path(
|
||||
DOMAIN, unique_id, CONF_SHC_KEY
|
||||
),
|
||||
CONF_SSL_CERTIFICATE: self.hass.config.path(DOMAIN, CONF_SHC_CERT),
|
||||
CONF_SSL_KEY: self.hass.config.path(DOMAIN, CONF_SHC_KEY),
|
||||
CONF_HOST: self.host,
|
||||
CONF_TOKEN: result["token"],
|
||||
CONF_HOSTNAME: result["token"].split(":", 1)[1],
|
||||
}
|
||||
existing_entry = await self.async_set_unique_id(unique_id)
|
||||
existing_entry = await self.async_set_unique_id(self.info["unique_id"])
|
||||
if existing_entry:
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry,
|
||||
|
||||
@@ -12,13 +12,6 @@
|
||||
},
|
||||
"list_language": {
|
||||
"default": "mdi:earth"
|
||||
},
|
||||
"list_access": {
|
||||
"default": "mdi:account-lock",
|
||||
"state": {
|
||||
"shared": "mdi:account-group",
|
||||
"invitation": "mdi:account-multiple-plus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"todo": {
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bring",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["bring-api==0.9.1"]
|
||||
"requirements": ["bring-api==0.9.0"]
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ class BringSensor(StrEnum):
|
||||
CONVENIENT = "convenient"
|
||||
DISCOUNTED = "discounted"
|
||||
LIST_LANGUAGE = "list_language"
|
||||
LIST_ACCESS = "list_access"
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
|
||||
@@ -74,14 +73,6 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
|
||||
options=[x.lower() for x in BRING_SUPPORTED_LOCALES],
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
),
|
||||
BringSensorEntityDescription(
|
||||
key=BringSensor.LIST_ACCESS,
|
||||
translation_key=BringSensor.LIST_ACCESS,
|
||||
value_fn=lambda lst, _: lst["status"].lower(),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
options=["registered", "shared", "invitation"],
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -61,14 +61,6 @@
|
||||
"sv-se": "Sweden",
|
||||
"tr-tr": "Türkiye"
|
||||
}
|
||||
},
|
||||
"list_access": {
|
||||
"name": "List access",
|
||||
"state": {
|
||||
"registered": "Private",
|
||||
"shared": "Shared",
|
||||
"invitation": "Invitation pending"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ from broadlink.exceptions import (
|
||||
)
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
@@ -200,4 +200,10 @@ class BroadlinkDevice(Generic[_ApiT]):
|
||||
self.api.host[0],
|
||||
)
|
||||
|
||||
self.config.async_start_reauth(self.hass, data={CONF_NAME: self.name})
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_REAUTH},
|
||||
data={CONF_NAME: self.name, **self.config.data},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,22 +2,79 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
import logging
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError, ServerDisconnectedError
|
||||
from brunt import BruntClientAsync, Thing
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import BruntConfigEntry, BruntCoordinator
|
||||
from .const import DATA_BAPI, DATA_COOR, DOMAIN, PLATFORMS, REGULAR_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BruntConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Brunt using config flow."""
|
||||
coordinator = BruntCoordinator(hass, entry)
|
||||
session = async_get_clientsession(hass)
|
||||
bapi = BruntClientAsync(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
try:
|
||||
await bapi.async_login()
|
||||
except ServerDisconnectedError as exc:
|
||||
raise ConfigEntryNotReady("Brunt not ready to connect.") from exc
|
||||
except ClientResponseError as exc:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Brunt could not connect with username: {entry.data[CONF_USERNAME]}."
|
||||
) from exc
|
||||
|
||||
async def async_update_data() -> dict[str | None, Thing]:
|
||||
"""Fetch data from the Brunt endpoint for all Things.
|
||||
|
||||
Error 403 is the API response for any kind of authentication error (failed password or email)
|
||||
Error 401 is the API response for things that are not part of the account, could happen when a device is deleted from the account.
|
||||
"""
|
||||
try:
|
||||
async with timeout(10):
|
||||
things = await bapi.async_get_things(force=True)
|
||||
return {thing.serial: thing for thing in things}
|
||||
except ServerDisconnectedError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
except ClientResponseError as err:
|
||||
if err.status == 403:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
if err.status == 401:
|
||||
_LOGGER.warning("Device not found, will reload Brunt integration")
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
raise UpdateFailed from err
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="brunt",
|
||||
update_method=async_update_data,
|
||||
update_interval=REGULAR_INTERVAL,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = {DATA_BAPI: bapi, DATA_COOR: coordinator}
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BruntConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
|
||||
@@ -10,6 +10,8 @@ NOTIFICATION_ID = "brunt_notification"
|
||||
NOTIFICATION_TITLE = "Brunt Cover Setup"
|
||||
ATTRIBUTION = "Based on an unofficial Brunt SDK."
|
||||
PLATFORMS = [Platform.COVER]
|
||||
DATA_BAPI = "bapi"
|
||||
DATA_COOR = "coordinator"
|
||||
|
||||
CLOSED_POSITION = 0
|
||||
OPEN_POSITION = 100
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""The brunt component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
import logging
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError, ServerDisconnectedError
|
||||
from brunt import BruntClientAsync, Thing
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import REGULAR_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type BruntConfigEntry = ConfigEntry[BruntCoordinator]
|
||||
|
||||
|
||||
class BruntCoordinator(DataUpdateCoordinator[dict[str | None, Thing]]):
|
||||
"""Config entry data."""
|
||||
|
||||
bapi: BruntClientAsync
|
||||
config_entry: BruntConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: BruntConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the Brunt coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="brunt",
|
||||
update_interval=REGULAR_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
self.bapi = BruntClientAsync(
|
||||
username=self.config_entry.data[CONF_USERNAME],
|
||||
password=self.config_entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
try:
|
||||
await self.bapi.async_login()
|
||||
except ServerDisconnectedError as exc:
|
||||
raise ConfigEntryNotReady("Brunt not ready to connect.") from exc
|
||||
except ClientResponseError as exc:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Brunt could not connect with username: {self.config_entry.data[CONF_USERNAME]}."
|
||||
) from exc
|
||||
|
||||
async def _async_update_data(self) -> dict[str | None, Thing]:
|
||||
"""Fetch data from the Brunt endpoint for all Things.
|
||||
|
||||
Error 403 is the API response for any kind of authentication error (failed password or email)
|
||||
Error 401 is the API response for things that are not part of the account, could happen when a device is deleted from the account.
|
||||
"""
|
||||
try:
|
||||
async with timeout(10):
|
||||
things = await self.bapi.async_get_things(force=True)
|
||||
return {thing.serial: thing for thing in things}
|
||||
except ServerDisconnectedError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
except ClientResponseError as err:
|
||||
if err.status == 403:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
if err.status == 401:
|
||||
_LOGGER.warning("Device not found, will reload Brunt integration")
|
||||
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||
raise UpdateFailed from err
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from brunt import Thing
|
||||
from brunt import BruntClientAsync, Thing
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
@@ -13,39 +13,49 @@ from homeassistant.components.cover import (
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
ATTR_REQUEST_POSITION,
|
||||
ATTRIBUTION,
|
||||
CLOSED_POSITION,
|
||||
DATA_BAPI,
|
||||
DATA_COOR,
|
||||
DOMAIN,
|
||||
FAST_INTERVAL,
|
||||
OPEN_POSITION,
|
||||
REGULAR_INTERVAL,
|
||||
)
|
||||
from .coordinator import BruntConfigEntry, BruntCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: BruntConfigEntry,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the brunt platform."""
|
||||
coordinator = entry.runtime_data
|
||||
bapi: BruntClientAsync = hass.data[DOMAIN][entry.entry_id][DATA_BAPI]
|
||||
coordinator: DataUpdateCoordinator[dict[str | None, Thing]] = hass.data[DOMAIN][
|
||||
entry.entry_id
|
||||
][DATA_COOR]
|
||||
|
||||
async_add_entities(
|
||||
BruntDevice(coordinator, serial, thing, entry.entry_id)
|
||||
BruntDevice(coordinator, serial, thing, bapi, entry.entry_id)
|
||||
for serial, thing in coordinator.data.items()
|
||||
)
|
||||
|
||||
|
||||
class BruntDevice(CoordinatorEntity[BruntCoordinator], CoverEntity):
|
||||
class BruntDevice(
|
||||
CoordinatorEntity[DataUpdateCoordinator[dict[str | None, Thing]]], CoverEntity
|
||||
):
|
||||
"""Representation of a Brunt cover device.
|
||||
|
||||
Contains the common logic for all Brunt devices.
|
||||
@@ -63,14 +73,16 @@ class BruntDevice(CoordinatorEntity[BruntCoordinator], CoverEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BruntCoordinator,
|
||||
coordinator: DataUpdateCoordinator[dict[str | None, Thing]],
|
||||
serial: str | None,
|
||||
thing: Thing,
|
||||
bapi: BruntClientAsync,
|
||||
entry_id: str,
|
||||
) -> None:
|
||||
"""Init the Brunt device."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = serial
|
||||
self._bapi = bapi
|
||||
self._thing = thing
|
||||
self._entry_id = entry_id
|
||||
|
||||
@@ -155,7 +167,7 @@ class BruntDevice(CoordinatorEntity[BruntCoordinator], CoverEntity):
|
||||
async def _async_update_cover(self, position: int) -> None:
|
||||
"""Set the cover to the new position and wait for the update to be reflected."""
|
||||
try:
|
||||
await self.coordinator.bapi.async_change_request_position(
|
||||
await self._bapi.async_change_request_position(
|
||||
position, thing_uri=self._thing.thing_uri
|
||||
)
|
||||
except ClientResponseError as exc:
|
||||
@@ -170,7 +182,7 @@ class BruntDevice(CoordinatorEntity[BruntCoordinator], CoverEntity):
|
||||
"""Update the update interval after each refresh."""
|
||||
if (
|
||||
self.request_cover_position
|
||||
== self.coordinator.bapi.last_requested_positions[self._thing.thing_uri]
|
||||
== self._bapi.last_requested_positions[self._thing.thing_uri]
|
||||
and self.move_state == 0
|
||||
):
|
||||
self.coordinator.update_interval = REGULAR_INTERVAL
|
||||
|
||||
@@ -364,7 +364,7 @@ SENSOR_DESCRIPTIONS = {
|
||||
): SensorEntityDescription(
|
||||
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
|
||||
device_class=SensorDeviceClass.CONDUCTIVITY,
|
||||
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
|
||||
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
},
|
||||
"get_events": {
|
||||
"service": "mdi:calendar-month"
|
||||
},
|
||||
"list_events": {
|
||||
"service": "mdi:calendar-month"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,22 @@ create_event:
|
||||
example: "Conference Room - F123, Bldg. 002"
|
||||
selector:
|
||||
text:
|
||||
list_events:
|
||||
target:
|
||||
entity:
|
||||
domain: calendar
|
||||
fields:
|
||||
start_date_time:
|
||||
example: "2022-03-22 20:00:00"
|
||||
selector:
|
||||
datetime:
|
||||
end_date_time:
|
||||
example: "2022-03-22 22:00:00"
|
||||
selector:
|
||||
datetime:
|
||||
duration:
|
||||
selector:
|
||||
duration:
|
||||
get_events:
|
||||
target:
|
||||
entity:
|
||||
|
||||
@@ -89,6 +89,24 @@
|
||||
"description": "Returns active events from start_date_time until the specified duration."
|
||||
}
|
||||
}
|
||||
},
|
||||
"list_events": {
|
||||
"name": "List event",
|
||||
"description": "Lists events on a calendar within a time range.",
|
||||
"fields": {
|
||||
"start_date_time": {
|
||||
"name": "[%key:component::calendar::services::get_events::fields::start_date_time::name%]",
|
||||
"description": "[%key:component::calendar::services::get_events::fields::start_date_time::description%]"
|
||||
},
|
||||
"end_date_time": {
|
||||
"name": "[%key:component::calendar::services::get_events::fields::end_date_time::name%]",
|
||||
"description": "[%key:component::calendar::services::get_events::fields::end_date_time::description%]"
|
||||
},
|
||||
"duration": {
|
||||
"name": "[%key:component::calendar::services::get_events::fields::duration::name%]",
|
||||
"description": "[%key:component::calendar::services::get_events::fields::duration::description%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS
|
||||
from .const import CONNECT_TIMEOUT, STREAM_MAGIC_EXCEPTIONS
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.SELECT, Platform.SWITCH]
|
||||
|
||||
@@ -45,13 +45,7 @@ async def async_setup_entry(
|
||||
async with asyncio.timeout(CONNECT_TIMEOUT):
|
||||
await client.connect()
|
||||
except STREAM_MAGIC_EXCEPTIONS as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entry_cannot_connect",
|
||||
translation_placeholders={
|
||||
"host": client.host,
|
||||
},
|
||||
) from err
|
||||
raise ConfigEntryNotReady(f"Error while connecting to {client.host}") from err
|
||||
entry.runtime_data = client
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -2,22 +2,20 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.redact import async_redact_data
|
||||
|
||||
from . import CambridgeAudioConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_HOST}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: CambridgeAudioConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for the provided config entry."""
|
||||
client = entry.runtime_data
|
||||
return {
|
||||
"display": client.display.to_dict(),
|
||||
"info": client.info.to_dict(),
|
||||
"now_playing": client.now_playing.to_dict(),
|
||||
"play_state": client.play_state.to_dict(),
|
||||
"presets_list": client.preset_list.to_dict(),
|
||||
"sources": [s.to_dict() for s in client.sources],
|
||||
"update": client.update.to_dict(),
|
||||
}
|
||||
return async_redact_data(
|
||||
{"info": client.info, "sources": client.sources}, TO_REDACT
|
||||
)
|
||||
|
||||
@@ -26,12 +26,7 @@ def command[_EntityT: CambridgeAudioEntity, **_P](
|
||||
await func(self, *args, **kwargs)
|
||||
except STREAM_MAGIC_EXCEPTIONS as exc:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_error",
|
||||
translation_placeholders={
|
||||
"function_name": func.__name__,
|
||||
"entity_id": self.entity_id,
|
||||
},
|
||||
f"Error executing {func.__name__} on entity {self.entity_id},"
|
||||
) from exc
|
||||
|
||||
return decorator
|
||||
@@ -67,4 +62,4 @@ class CambridgeAudioEntity(Entity):
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Remove callbacks."""
|
||||
self.client.unregister_state_update_callbacks(self._state_update_callback)
|
||||
await self.client.unregister_state_update_callbacks(self._state_update_callback)
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
"dim": "mdi:brightness-6",
|
||||
"off": "mdi:brightness-3"
|
||||
}
|
||||
},
|
||||
"audio_output": {
|
||||
"default": "mdi:audio-input-stereo-minijack"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiostreammagic"],
|
||||
"requirements": ["aiostreammagic==2.8.4"],
|
||||
"requirements": ["aiostreammagic==2.8.1"],
|
||||
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -177,9 +177,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
|
||||
return volume / 100
|
||||
|
||||
@property
|
||||
def shuffle(self) -> bool:
|
||||
def shuffle(self) -> bool | None:
|
||||
"""Current shuffle configuration."""
|
||||
return self.client.play_state.mode_shuffle != ShuffleMode.OFF
|
||||
mode_shuffle = self.client.play_state.mode_shuffle
|
||||
if not mode_shuffle:
|
||||
return False
|
||||
return mode_shuffle != ShuffleMode.OFF
|
||||
|
||||
@property
|
||||
def repeat(self) -> RepeatMode | None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support for Cambridge Audio select entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiostreammagic import StreamMagicClient
|
||||
from aiostreammagic.models import DisplayBrightness
|
||||
@@ -19,34 +19,10 @@ from .entity import CambridgeAudioEntity
|
||||
class CambridgeAudioSelectEntityDescription(SelectEntityDescription):
|
||||
"""Describes Cambridge Audio select entity."""
|
||||
|
||||
options_fn: Callable[[StreamMagicClient], list[str]] = field(default=lambda _: [])
|
||||
load_fn: Callable[[StreamMagicClient], bool] = field(default=lambda _: True)
|
||||
value_fn: Callable[[StreamMagicClient], str | None]
|
||||
set_value_fn: Callable[[StreamMagicClient, str], Awaitable[None]]
|
||||
|
||||
|
||||
async def _audio_output_set_value_fn(client: StreamMagicClient, value: str) -> None:
|
||||
"""Set the audio output using the display name."""
|
||||
audio_output_id = next(
|
||||
(output.id for output in client.audio_output.outputs if value == output.name),
|
||||
None,
|
||||
)
|
||||
assert audio_output_id is not None
|
||||
await client.set_audio_output(audio_output_id)
|
||||
|
||||
|
||||
def _audio_output_value_fn(client: StreamMagicClient) -> str | None:
|
||||
"""Convert the current audio output id to name."""
|
||||
return next(
|
||||
(
|
||||
output.name
|
||||
for output in client.audio_output.outputs
|
||||
if client.state.audio_output == output.id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
|
||||
CambridgeAudioSelectEntityDescription(
|
||||
key="display_brightness",
|
||||
@@ -58,17 +34,6 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
|
||||
DisplayBrightness(value)
|
||||
),
|
||||
),
|
||||
CambridgeAudioSelectEntityDescription(
|
||||
key="audio_output",
|
||||
translation_key="audio_output",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
options_fn=lambda client: [
|
||||
output.name for output in client.audio_output.outputs
|
||||
],
|
||||
load_fn=lambda client: len(client.audio_output.outputs) > 0,
|
||||
value_fn=_audio_output_value_fn,
|
||||
set_value_fn=_audio_output_set_value_fn,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -81,9 +46,7 @@ async def async_setup_entry(
|
||||
|
||||
client: StreamMagicClient = entry.runtime_data
|
||||
entities: list[CambridgeAudioSelect] = [
|
||||
CambridgeAudioSelect(client, description)
|
||||
for description in CONTROL_ENTITIES
|
||||
if description.load_fn(client)
|
||||
CambridgeAudioSelect(client, description) for description in CONTROL_ENTITIES
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -102,9 +65,6 @@ class CambridgeAudioSelect(CambridgeAudioEntity, SelectEntity):
|
||||
super().__init__(client)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{client.info.unit_id}-{description.key}"
|
||||
options_fn = description.options_fn(client)
|
||||
if options_fn:
|
||||
self._attr_options = options_fn
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
|
||||
@@ -32,9 +32,6 @@
|
||||
"dim": "Dim",
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
"audio_output": {
|
||||
"name": "Audio output"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
@@ -55,12 +52,6 @@
|
||||
},
|
||||
"preset_non_integer": {
|
||||
"message": "Preset must be an integer, got: {preset_id}"
|
||||
},
|
||||
"entry_cannot_connect": {
|
||||
"message": "Error while connecting to {host}"
|
||||
},
|
||||
"command_error": {
|
||||
"message": "Error executing {function_name} on entity {entity_id}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict, dataclass
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timedelta
|
||||
from enum import IntFlag
|
||||
from functools import partial
|
||||
@@ -18,9 +18,9 @@ from typing import Any, Final, final
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
import attr
|
||||
from propcache import cached_property, under_cached_property
|
||||
from propcache import cached_property
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCIceCandidate, RTCIceServer
|
||||
from webrtc_models import RTCIceServer
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
@@ -86,20 +86,12 @@ from .img_util import scale_jpeg_camera_image
|
||||
from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401
|
||||
from .webrtc import (
|
||||
DATA_ICE_SERVERS,
|
||||
CameraWebRTCLegacyProvider,
|
||||
CameraWebRTCProvider,
|
||||
WebRTCAnswer,
|
||||
WebRTCCandidate, # noqa: F401
|
||||
WebRTCClientConfiguration,
|
||||
WebRTCError,
|
||||
WebRTCMessage, # noqa: F401
|
||||
WebRTCSendMessage,
|
||||
async_get_supported_legacy_provider,
|
||||
async_get_supported_provider,
|
||||
async_get_supported_providers,
|
||||
async_register_ice_servers,
|
||||
async_register_rtsp_to_web_rtc_provider, # noqa: F401
|
||||
async_register_webrtc_provider, # noqa: F401
|
||||
async_register_ws,
|
||||
ws_get_client_config,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -177,13 +169,6 @@ class Image:
|
||||
content: bytes = attr.ib()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraCapabilities:
|
||||
"""Camera capabilities."""
|
||||
|
||||
frontend_stream_types: set[StreamType]
|
||||
|
||||
|
||||
@bind_hass
|
||||
async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str:
|
||||
"""Request a stream for a camera entity."""
|
||||
@@ -357,10 +342,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.http.register_view(CameraMjpegStream(component))
|
||||
|
||||
websocket_api.async_register_command(hass, ws_camera_stream)
|
||||
websocket_api.async_register_command(hass, ws_camera_web_rtc_offer)
|
||||
websocket_api.async_register_command(hass, websocket_get_prefs)
|
||||
websocket_api.async_register_command(hass, websocket_update_prefs)
|
||||
websocket_api.async_register_command(hass, ws_camera_capabilities)
|
||||
async_register_ws(hass)
|
||||
websocket_api.async_register_command(hass, ws_get_client_config)
|
||||
|
||||
await component.async_setup(config)
|
||||
|
||||
@@ -420,10 +405,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
def get_ice_servers() -> list[RTCIceServer]:
|
||||
if hass.config.webrtc.ice_servers:
|
||||
return hass.config.webrtc.ice_servers
|
||||
return [
|
||||
RTCIceServer(urls="stun:stun.home-assistant.io:80"),
|
||||
RTCIceServer(urls="stun:stun.home-assistant.io:3478"),
|
||||
]
|
||||
return [RTCIceServer(urls="stun:stun.home-assistant.io:80")]
|
||||
|
||||
async_register_ice_servers(hass, get_ice_servers)
|
||||
return True
|
||||
@@ -472,11 +454,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_state: None = None # State is determined by is_on
|
||||
_attr_supported_features: CameraEntityFeature = CameraEntityFeature(0)
|
||||
|
||||
__supports_stream: CameraEntityFeature | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a camera."""
|
||||
self._cache: dict[str, Any] = {}
|
||||
self.stream: Stream | None = None
|
||||
self.stream_options: dict[str, str | bool | float] = {}
|
||||
self.content_type: str = DEFAULT_CONTENT_TYPE
|
||||
@@ -484,15 +463,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
self._warned_old_signature = False
|
||||
self.async_update_token()
|
||||
self._create_stream_lock: asyncio.Lock | None = None
|
||||
self._webrtc_provider: CameraWebRTCProvider | None = None
|
||||
self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None
|
||||
self._supports_native_sync_webrtc = (
|
||||
type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer
|
||||
)
|
||||
self._supports_native_async_webrtc = (
|
||||
type(self).async_handle_async_webrtc_offer
|
||||
!= Camera.async_handle_async_webrtc_offer
|
||||
)
|
||||
self._webrtc_providers: list[CameraWebRTCProvider] = []
|
||||
|
||||
@cached_property
|
||||
def entity_picture(self) -> str:
|
||||
@@ -566,7 +537,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
return self._attr_frontend_stream_type
|
||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||
return None
|
||||
if self._webrtc_provider or self._legacy_webrtc_provider:
|
||||
if self._webrtc_providers:
|
||||
return StreamType.WEB_RTC
|
||||
return StreamType.HLS
|
||||
|
||||
@@ -616,66 +587,12 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
Integrations can override with a native WebRTC implementation.
|
||||
"""
|
||||
|
||||
async def async_handle_async_webrtc_offer(
|
||||
self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage
|
||||
) -> None:
|
||||
"""Handle the async WebRTC offer.
|
||||
|
||||
Async means that it could take some time to process the offer and responses/message
|
||||
will be sent with the send_message callback.
|
||||
This method is used by cameras with CameraEntityFeature.STREAM and StreamType.WEB_RTC.
|
||||
An integration overriding this method must also implement async_on_webrtc_candidate.
|
||||
|
||||
Integrations can override with a native WebRTC implementation.
|
||||
"""
|
||||
if self._supports_native_sync_webrtc:
|
||||
try:
|
||||
answer = await self.async_handle_web_rtc_offer(offer_sdp)
|
||||
except ValueError as ex:
|
||||
_LOGGER.error("Error handling WebRTC offer: %s", ex)
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
str(ex),
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
# This catch was already here and should stay through the deprecation
|
||||
_LOGGER.error("Timeout handling WebRTC offer")
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
"Timeout handling WebRTC offer",
|
||||
)
|
||||
)
|
||||
else:
|
||||
if answer:
|
||||
send_message(WebRTCAnswer(answer))
|
||||
else:
|
||||
_LOGGER.error("Error handling WebRTC offer: No answer")
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
"No answer on WebRTC offer",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if self._webrtc_provider:
|
||||
await self._webrtc_provider.async_handle_async_webrtc_offer(
|
||||
self, offer_sdp, session_id, send_message
|
||||
)
|
||||
return
|
||||
|
||||
if self._legacy_webrtc_provider and (
|
||||
answer := await self._legacy_webrtc_provider.async_handle_web_rtc_offer(
|
||||
self, offer_sdp
|
||||
)
|
||||
):
|
||||
send_message(WebRTCAnswer(answer))
|
||||
else:
|
||||
raise HomeAssistantError("Camera does not support WebRTC")
|
||||
for provider in self._webrtc_providers:
|
||||
if answer := await provider.async_handle_web_rtc_offer(self, offer_sdp):
|
||||
return answer
|
||||
raise HomeAssistantError(
|
||||
"WebRTC offer was not accepted by the supported providers"
|
||||
)
|
||||
|
||||
def camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
@@ -785,133 +702,57 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
self.__supports_stream = (
|
||||
self.supported_features_compat & CameraEntityFeature.STREAM
|
||||
)
|
||||
await self.async_refresh_providers(write_state=False)
|
||||
# Avoid calling async_refresh_providers() in here because it
|
||||
# it will write state a second time since state is always
|
||||
# written when an entity is added to hass.
|
||||
self._webrtc_providers = await self._async_get_supported_webrtc_providers()
|
||||
|
||||
async def async_refresh_providers(self, *, write_state: bool = True) -> None:
|
||||
async def async_refresh_providers(self) -> None:
|
||||
"""Determine if any of the registered providers are suitable for this entity.
|
||||
|
||||
This affects state attributes, so it should be invoked any time the registered
|
||||
providers or inputs to the state attributes change.
|
||||
|
||||
Returns True if any state was updated (and needs to be written)
|
||||
"""
|
||||
old_provider = self._webrtc_provider
|
||||
old_legacy_provider = self._legacy_webrtc_provider
|
||||
new_provider = None
|
||||
new_legacy_provider = None
|
||||
old_providers = self._webrtc_providers
|
||||
new_providers = await self._async_get_supported_webrtc_providers()
|
||||
self._webrtc_providers = new_providers
|
||||
if old_providers != new_providers:
|
||||
self.async_write_ha_state()
|
||||
|
||||
# Skip all providers if the camera has a native WebRTC implementation
|
||||
if not (
|
||||
self._supports_native_sync_webrtc or self._supports_native_async_webrtc
|
||||
):
|
||||
# Camera doesn't have a native WebRTC implementation
|
||||
new_provider = await self._async_get_supported_webrtc_provider(
|
||||
async_get_supported_provider
|
||||
)
|
||||
|
||||
if new_provider is None:
|
||||
# Only add the legacy provider if the new provider is not available
|
||||
new_legacy_provider = await self._async_get_supported_webrtc_provider(
|
||||
async_get_supported_legacy_provider
|
||||
)
|
||||
|
||||
if old_provider != new_provider or old_legacy_provider != new_legacy_provider:
|
||||
self._webrtc_provider = new_provider
|
||||
self._legacy_webrtc_provider = new_legacy_provider
|
||||
self._invalidate_camera_capabilities_cache()
|
||||
if write_state:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_get_supported_webrtc_provider[_T](
|
||||
self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]]
|
||||
) -> _T | None:
|
||||
"""Get first provider that supports this camera."""
|
||||
async def _async_get_supported_webrtc_providers(
|
||||
self,
|
||||
) -> list[CameraWebRTCProvider]:
|
||||
"""Get the all providers that supports this camera."""
|
||||
if CameraEntityFeature.STREAM not in self.supported_features_compat:
|
||||
return None
|
||||
return []
|
||||
|
||||
return await fn(self.hass, self)
|
||||
return await async_get_supported_providers(self.hass, self)
|
||||
|
||||
@callback
|
||||
def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
|
||||
@property
|
||||
def webrtc_providers(self) -> list[CameraWebRTCProvider]:
|
||||
"""Return the WebRTC providers."""
|
||||
return self._webrtc_providers
|
||||
|
||||
async def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
|
||||
"""Return the WebRTC client configuration adjustable per integration."""
|
||||
return WebRTCClientConfiguration()
|
||||
|
||||
@final
|
||||
@callback
|
||||
def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
|
||||
async def async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration:
|
||||
"""Return the WebRTC client configuration and extend it with the registered ice servers."""
|
||||
config = self._async_get_webrtc_client_configuration()
|
||||
config = await self._async_get_webrtc_client_configuration()
|
||||
|
||||
if not self._supports_native_sync_webrtc:
|
||||
# Until 2024.11, the frontend was not resolving any ice servers
|
||||
# The async approach was added 2024.11 and new integrations need to use it
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
|
||||
config.get_candidates_upfront = (
|
||||
self._supports_native_sync_webrtc
|
||||
or self._legacy_webrtc_provider is not None
|
||||
)
|
||||
ice_servers = [
|
||||
server
|
||||
for servers in self.hass.data.get(DATA_ICE_SERVERS, [])
|
||||
for server in servers()
|
||||
]
|
||||
config.configuration.ice_servers.extend(ice_servers)
|
||||
|
||||
return config
|
||||
|
||||
async def async_on_webrtc_candidate(
|
||||
self, session_id: str, candidate: RTCIceCandidate
|
||||
) -> None:
|
||||
"""Handle a WebRTC candidate."""
|
||||
if self._webrtc_provider:
|
||||
await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate)
|
||||
else:
|
||||
raise HomeAssistantError("Cannot handle WebRTC candidate")
|
||||
|
||||
@callback
|
||||
def close_webrtc_session(self, session_id: str) -> None:
|
||||
"""Close a WebRTC session."""
|
||||
if self._webrtc_provider:
|
||||
self._webrtc_provider.async_close_session(session_id)
|
||||
|
||||
@callback
|
||||
def _invalidate_camera_capabilities_cache(self) -> None:
|
||||
"""Invalidate the camera capabilities cache."""
|
||||
self._cache.pop("camera_capabilities", None)
|
||||
|
||||
@final
|
||||
@under_cached_property
|
||||
def camera_capabilities(self) -> CameraCapabilities:
|
||||
"""Return the camera capabilities."""
|
||||
frontend_stream_types = set()
|
||||
if CameraEntityFeature.STREAM in self.supported_features_compat:
|
||||
if self._supports_native_sync_webrtc or self._supports_native_async_webrtc:
|
||||
# The camera has a native WebRTC implementation
|
||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||
else:
|
||||
frontend_stream_types.add(StreamType.HLS)
|
||||
|
||||
if self._webrtc_provider:
|
||||
frontend_stream_types.add(StreamType.WEB_RTC)
|
||||
|
||||
return CameraCapabilities(frontend_stream_types)
|
||||
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine.
|
||||
|
||||
Schedules async_refresh_providers if support of streams have changed.
|
||||
"""
|
||||
super().async_write_ha_state()
|
||||
if self.__supports_stream != (
|
||||
supports_stream := self.supported_features_compat
|
||||
& CameraEntityFeature.STREAM
|
||||
):
|
||||
self.__supports_stream = supports_stream
|
||||
self._invalidate_camera_capabilities_cache()
|
||||
self.hass.async_create_task(self.async_refresh_providers())
|
||||
|
||||
|
||||
class CameraView(HomeAssistantView):
|
||||
"""Base CameraView."""
|
||||
@@ -1002,24 +843,6 @@ class CameraMjpegStream(CameraView):
|
||||
raise web.HTTPBadRequest from err
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "camera/capabilities",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_camera_capabilities(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle get camera capabilities websocket command.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
camera = get_camera_from_entity_id(hass, msg["entity_id"])
|
||||
connection.send_result(msg["id"], asdict(camera.camera_capabilities))
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "camera/stream",
|
||||
@@ -1050,6 +873,53 @@ async def ws_camera_stream(
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "camera/web_rtc_offer",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
vol.Required("offer"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_camera_web_rtc_offer(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle the signal path for a WebRTC stream.
|
||||
|
||||
This signal path is used to route the offer created by the client to the
|
||||
camera device through the integration for negotiation on initial setup,
|
||||
which returns an answer. The actual streaming is handled entirely between
|
||||
the client and camera device.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
entity_id = msg["entity_id"]
|
||||
offer = msg["offer"]
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
if camera.frontend_stream_type != StreamType.WEB_RTC:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"web_rtc_offer_failed",
|
||||
(
|
||||
"Camera does not support WebRTC,"
|
||||
f" frontend_stream_type={camera.frontend_stream_type}"
|
||||
),
|
||||
)
|
||||
return
|
||||
try:
|
||||
answer = await camera.async_handle_web_rtc_offer(offer)
|
||||
except (HomeAssistantError, ValueError) as ex:
|
||||
_LOGGER.error("Error handling WebRTC offer: %s", ex)
|
||||
connection.send_error(msg["id"], "web_rtc_offer_failed", str(ex))
|
||||
except TimeoutError:
|
||||
_LOGGER.error("Timeout handling WebRTC offer")
|
||||
connection.send_error(
|
||||
msg["id"], "web_rtc_offer_failed", "Timeout handling WebRTC offer"
|
||||
)
|
||||
else:
|
||||
connection.send_result(msg["id"], {"answer": answer})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{vol.Required("type"): "camera/get_prefs", vol.Required("entity_id"): cv.entity_id}
|
||||
)
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"legacy_webrtc_provider": {
|
||||
"title": "Detected use of legacy WebRTC provider registered by {legacy_integration}",
|
||||
"description": "The {legacy_integration} integration has registered a legacy WebRTC provider. Home Assistant prefers using the built-in modern WebRTC provider registered by the {builtin_integration} integration.\n\nBenefits of the built-in integration are:\n\n- The camera stream is started faster.\n- More camera devices are supported.\n\nTo fix this issue, you can either keep using the built-in modern WebRTC provider and remove the {legacy_integration} integration or remove the {builtin_integration} integration to use the legacy provider, and then restart Home Assistant."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -2,23 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from functools import cache, partial
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
import voluptuous as vol
|
||||
from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer
|
||||
from webrtc_models import RTCConfiguration, RTCIceServer
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.ulid import ulid
|
||||
|
||||
from .const import DATA_COMPONENT, DOMAIN, StreamType
|
||||
from .helper import get_camera_from_entity_id
|
||||
@@ -26,79 +21,15 @@ from .helper import get_camera_from_entity_id
|
||||
if TYPE_CHECKING:
|
||||
from . import Camera
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DATA_WEBRTC_PROVIDERS: HassKey[set[CameraWebRTCProvider]] = HassKey(
|
||||
"camera_webrtc_providers"
|
||||
)
|
||||
DATA_WEBRTC_LEGACY_PROVIDERS: HassKey[dict[str, CameraWebRTCLegacyProvider]] = HassKey(
|
||||
"camera_webrtc_legacy_providers"
|
||||
"camera_web_rtc_providers"
|
||||
)
|
||||
DATA_ICE_SERVERS: HassKey[list[Callable[[], Iterable[RTCIceServer]]]] = HassKey(
|
||||
"camera_webrtc_ice_servers"
|
||||
"camera_web_rtc_ice_servers"
|
||||
)
|
||||
|
||||
|
||||
_WEBRTC = "WebRTC"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebRTCMessage:
|
||||
"""Base class for WebRTC messages."""
|
||||
|
||||
@classmethod
|
||||
@cache
|
||||
def _get_type(cls) -> str:
|
||||
_, _, name = cls.__name__.partition(_WEBRTC)
|
||||
return name.lower()
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dict representation of the message."""
|
||||
data = asdict(self)
|
||||
data["type"] = self._get_type()
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebRTCSession(WebRTCMessage):
|
||||
"""WebRTC session."""
|
||||
|
||||
session_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebRTCAnswer(WebRTCMessage):
|
||||
"""WebRTC answer."""
|
||||
|
||||
answer: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebRTCCandidate(WebRTCMessage):
|
||||
"""WebRTC candidate."""
|
||||
|
||||
candidate: RTCIceCandidate
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dict representation of the message."""
|
||||
return {
|
||||
"type": self._get_type(),
|
||||
"candidate": self.candidate.candidate,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebRTCError(WebRTCMessage):
|
||||
"""WebRTC error."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
type WebRTCSendMessage = Callable[[WebRTCMessage], None]
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class WebRTCClientConfiguration:
|
||||
"""WebRTC configuration for the client.
|
||||
@@ -108,55 +39,18 @@ class WebRTCClientConfiguration:
|
||||
|
||||
configuration: RTCConfiguration = field(default_factory=RTCConfiguration)
|
||||
data_channel: str | None = None
|
||||
get_candidates_upfront: bool = False
|
||||
|
||||
def to_frontend_dict(self) -> dict[str, Any]:
|
||||
"""Return a dict that can be used by the frontend."""
|
||||
data: dict[str, Any] = {
|
||||
"configuration": self.configuration.to_dict(),
|
||||
"getCandidatesUpfront": self.get_candidates_upfront,
|
||||
}
|
||||
if self.data_channel is not None:
|
||||
data["dataChannel"] = self.data_channel
|
||||
return data
|
||||
|
||||
|
||||
class CameraWebRTCProvider(ABC):
|
||||
"""WebRTC provider."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def domain(self) -> str:
|
||||
"""Return the integration domain of the provider."""
|
||||
|
||||
@callback
|
||||
@abstractmethod
|
||||
def async_is_supported(self, stream_source: str) -> bool:
|
||||
"""Determine if the provider supports the stream source."""
|
||||
|
||||
@abstractmethod
|
||||
async def async_handle_async_webrtc_offer(
|
||||
self,
|
||||
camera: Camera,
|
||||
offer_sdp: str,
|
||||
session_id: str,
|
||||
send_message: WebRTCSendMessage,
|
||||
) -> None:
|
||||
"""Handle the WebRTC offer and return the answer via the provided callback."""
|
||||
|
||||
@abstractmethod
|
||||
async def async_on_webrtc_candidate(
|
||||
self, session_id: str, candidate: RTCIceCandidate
|
||||
) -> None:
|
||||
"""Handle the WebRTC candidate."""
|
||||
|
||||
@callback
|
||||
def async_close_session(self, session_id: str) -> None:
|
||||
"""Close the session."""
|
||||
return ## This is an optional method so we need a default here.
|
||||
|
||||
|
||||
class CameraWebRTCLegacyProvider(Protocol):
|
||||
class CameraWebRTCProvider(Protocol):
|
||||
"""WebRTC provider."""
|
||||
|
||||
async def async_is_supported(self, stream_source: str) -> bool:
|
||||
@@ -168,7 +62,6 @@ class CameraWebRTCLegacyProvider(Protocol):
|
||||
"""Handle the WebRTC offer and return an answer."""
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_webrtc_provider(
|
||||
hass: HomeAssistant,
|
||||
provider: CameraWebRTCProvider,
|
||||
@@ -180,7 +73,9 @@ def async_register_webrtc_provider(
|
||||
if DOMAIN not in hass.data:
|
||||
raise ValueError("Unexpected state, camera not loaded")
|
||||
|
||||
providers = hass.data.setdefault(DATA_WEBRTC_PROVIDERS, set())
|
||||
providers: set[CameraWebRTCProvider] = hass.data.setdefault(
|
||||
DATA_WEBRTC_PROVIDERS, set()
|
||||
)
|
||||
|
||||
@callback
|
||||
def remove_provider() -> None:
|
||||
@@ -197,7 +92,6 @@ def async_register_webrtc_provider(
|
||||
|
||||
async def _async_refresh_providers(hass: HomeAssistant) -> None:
|
||||
"""Check all cameras for any state changes for registered providers."""
|
||||
_async_check_conflicting_legacy_provider(hass)
|
||||
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await asyncio.gather(
|
||||
@@ -205,72 +99,6 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "camera/webrtc/offer",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
vol.Required("offer"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_webrtc_offer(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle the signal path for a WebRTC stream.
|
||||
|
||||
This signal path is used to route the offer created by the client to the
|
||||
camera device through the integration for negotiation on initial setup.
|
||||
The ws endpoint returns a subscription id, where ice candidates and the
|
||||
final answer will be returned.
|
||||
The actual streaming is handled entirely between the client and camera device.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
entity_id = msg["entity_id"]
|
||||
offer = msg["offer"]
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
if camera.frontend_stream_type != StreamType.WEB_RTC:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"webrtc_offer_failed",
|
||||
(
|
||||
"Camera does not support WebRTC,"
|
||||
f" frontend_stream_type={camera.frontend_stream_type}"
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
session_id = ulid()
|
||||
connection.subscriptions[msg["id"]] = partial(
|
||||
camera.close_webrtc_session, session_id
|
||||
)
|
||||
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
@callback
|
||||
def send_message(message: WebRTCMessage) -> None:
|
||||
"""Push a value to websocket."""
|
||||
connection.send_message(
|
||||
websocket_api.event_message(
|
||||
msg["id"],
|
||||
message.as_dict(),
|
||||
)
|
||||
)
|
||||
|
||||
send_message(WebRTCSession(session_id))
|
||||
|
||||
try:
|
||||
await camera.async_handle_async_webrtc_offer(offer, session_id, send_message)
|
||||
except HomeAssistantError as ex:
|
||||
_LOGGER.error("Error handling WebRTC offer: %s", ex)
|
||||
send_message(
|
||||
WebRTCError(
|
||||
"webrtc_offer_failed",
|
||||
str(ex),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "camera/webrtc/get_client_config",
|
||||
@@ -287,7 +115,7 @@ async def ws_get_client_config(
|
||||
if camera.frontend_stream_type != StreamType.WEB_RTC:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"webrtc_get_client_config_failed",
|
||||
"web_rtc_offer_failed",
|
||||
(
|
||||
"Camera does not support WebRTC,"
|
||||
f" frontend_stream_type={camera.frontend_stream_type}"
|
||||
@@ -295,82 +123,26 @@ async def ws_get_client_config(
|
||||
)
|
||||
return
|
||||
|
||||
config = camera.async_get_webrtc_client_configuration().to_frontend_dict()
|
||||
config = (await camera.async_get_webrtc_client_configuration()).to_frontend_dict()
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "camera/webrtc/candidate",
|
||||
vol.Required("entity_id"): cv.entity_id,
|
||||
vol.Required("session_id"): str,
|
||||
vol.Required("candidate"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_candidate(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle WebRTC candidate websocket command."""
|
||||
entity_id = msg["entity_id"]
|
||||
camera = get_camera_from_entity_id(hass, entity_id)
|
||||
if camera.frontend_stream_type != StreamType.WEB_RTC:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"webrtc_candidate_failed",
|
||||
(
|
||||
"Camera does not support WebRTC,"
|
||||
f" frontend_stream_type={camera.frontend_stream_type}"
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
await camera.async_on_webrtc_candidate(
|
||||
msg["session_id"], RTCIceCandidate(msg["candidate"])
|
||||
)
|
||||
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_ws(hass: HomeAssistant) -> None:
|
||||
"""Register camera webrtc ws endpoints."""
|
||||
|
||||
websocket_api.async_register_command(hass, ws_webrtc_offer)
|
||||
websocket_api.async_register_command(hass, ws_get_client_config)
|
||||
websocket_api.async_register_command(hass, ws_candidate)
|
||||
|
||||
|
||||
async def async_get_supported_provider(
|
||||
async def async_get_supported_providers(
|
||||
hass: HomeAssistant, camera: Camera
|
||||
) -> CameraWebRTCProvider | None:
|
||||
"""Return the first supported provider for the camera."""
|
||||
) -> list[CameraWebRTCProvider]:
|
||||
"""Return a list of supported providers for the camera."""
|
||||
providers = hass.data.get(DATA_WEBRTC_PROVIDERS)
|
||||
if not providers or not (stream_source := await camera.stream_source()):
|
||||
return None
|
||||
return []
|
||||
|
||||
for provider in providers:
|
||||
if provider.async_is_supported(stream_source):
|
||||
return provider
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def async_get_supported_legacy_provider(
|
||||
hass: HomeAssistant, camera: Camera
|
||||
) -> CameraWebRTCLegacyProvider | None:
|
||||
"""Return the first supported provider for the camera."""
|
||||
providers = hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS)
|
||||
if not providers or not (stream_source := await camera.stream_source()):
|
||||
return None
|
||||
|
||||
for provider in providers.values():
|
||||
if await provider.async_is_supported(stream_source):
|
||||
return provider
|
||||
|
||||
return None
|
||||
return [
|
||||
provider
|
||||
for provider in providers
|
||||
if await provider.async_is_supported(stream_source)
|
||||
]
|
||||
|
||||
|
||||
@callback
|
||||
@@ -405,7 +177,7 @@ _RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"}
|
||||
type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]]
|
||||
|
||||
|
||||
class _CameraRtspToWebRTCProvider(CameraWebRTCLegacyProvider):
|
||||
class _CameraRtspToWebRTCProvider(CameraWebRTCProvider):
|
||||
def __init__(self, fn: RtspToWebRtcProviderType) -> None:
|
||||
"""Initialize the RTSP to WebRTC provider."""
|
||||
self._fn = fn
|
||||
@@ -433,49 +205,5 @@ def async_register_rtsp_to_web_rtc_provider(
|
||||
|
||||
The first provider to satisfy the offer will be used.
|
||||
"""
|
||||
if DOMAIN not in hass.data:
|
||||
raise ValueError("Unexpected state, camera not loaded")
|
||||
|
||||
legacy_providers = hass.data.setdefault(DATA_WEBRTC_LEGACY_PROVIDERS, {})
|
||||
|
||||
if domain in legacy_providers:
|
||||
raise ValueError("Provider already registered")
|
||||
|
||||
provider_instance = _CameraRtspToWebRTCProvider(provider)
|
||||
|
||||
@callback
|
||||
def remove_provider() -> None:
|
||||
legacy_providers.pop(domain)
|
||||
hass.async_create_task(_async_refresh_providers(hass))
|
||||
|
||||
legacy_providers[domain] = provider_instance
|
||||
hass.async_create_task(_async_refresh_providers(hass))
|
||||
|
||||
return remove_provider
|
||||
|
||||
|
||||
@callback
|
||||
def _async_check_conflicting_legacy_provider(hass: HomeAssistant) -> None:
|
||||
"""Check if a legacy provider is registered together with the builtin provider."""
|
||||
builtin_provider_domain = "go2rtc"
|
||||
if (
|
||||
(legacy_providers := hass.data.get(DATA_WEBRTC_LEGACY_PROVIDERS))
|
||||
and (providers := hass.data.get(DATA_WEBRTC_PROVIDERS))
|
||||
and any(provider.domain == builtin_provider_domain for provider in providers)
|
||||
):
|
||||
for domain in legacy_providers:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"legacy_webrtc_provider_{domain}",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=domain,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/go2rtc/",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="legacy_webrtc_provider",
|
||||
translation_placeholders={
|
||||
"legacy_integration": domain,
|
||||
"builtin_integration": builtin_provider_domain,
|
||||
},
|
||||
)
|
||||
return async_register_webrtc_provider(hass, provider_instance)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["casttube", "pychromecast"],
|
||||
"requirements": ["PyChromecast==14.0.5"],
|
||||
"requirements": ["PyChromecast==14.0.4"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_googlecast._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
@@ -12,14 +11,12 @@ from typing import Any, Literal
|
||||
|
||||
import aiohttp
|
||||
from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed
|
||||
from webrtc_models import RTCIceServer
|
||||
|
||||
from homeassistant.components import google_assistant, persistent_notification, webhook
|
||||
from homeassistant.components.alexa import (
|
||||
errors as alexa_errors,
|
||||
smart_home as alexa_smart_home,
|
||||
)
|
||||
from homeassistant.components.camera.webrtc import async_register_ice_servers
|
||||
from homeassistant.components.google_assistant import smart_home as ga
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import Context, HassJob, HomeAssistant, callback
|
||||
@@ -30,7 +27,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
|
||||
from homeassistant.util.aiohttp import MockRequest, serialize_response
|
||||
|
||||
from . import alexa_config, google_config
|
||||
from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN, PREF_ENABLE_CLOUD_ICE_SERVERS
|
||||
from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN
|
||||
from .prefs import CloudPreferences
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -63,7 +60,6 @@ class CloudClient(Interface):
|
||||
self._alexa_config_init_lock = asyncio.Lock()
|
||||
self._google_config_init_lock = asyncio.Lock()
|
||||
self._relayer_region: str | None = None
|
||||
self._cloud_ice_servers_listener: Callable[[], None] | None = None
|
||||
|
||||
@property
|
||||
def base_path(self) -> Path:
|
||||
@@ -191,49 +187,6 @@ class CloudClient(Interface):
|
||||
if is_new_user:
|
||||
await gconf.async_sync_entities(gconf.agent_user_id)
|
||||
|
||||
async def setup_cloud_ice_servers(_: datetime) -> None:
|
||||
async def register_cloud_ice_server(
|
||||
ice_servers: list[RTCIceServer],
|
||||
) -> Callable[[], None]:
|
||||
"""Register cloud ice server."""
|
||||
|
||||
def get_ice_servers() -> list[RTCIceServer]:
|
||||
return ice_servers
|
||||
|
||||
return async_register_ice_servers(self._hass, get_ice_servers)
|
||||
|
||||
async def async_register_cloud_ice_servers_listener(
|
||||
prefs: CloudPreferences,
|
||||
) -> None:
|
||||
is_cloud_ice_servers_enabled = (
|
||||
self.cloud.is_logged_in
|
||||
and not self.cloud.subscription_expired
|
||||
and prefs.cloud_ice_servers_enabled
|
||||
)
|
||||
if is_cloud_ice_servers_enabled:
|
||||
if self._cloud_ice_servers_listener is None:
|
||||
self._cloud_ice_servers_listener = await self.cloud.ice_servers.async_register_ice_servers_listener(
|
||||
register_cloud_ice_server
|
||||
)
|
||||
elif self._cloud_ice_servers_listener:
|
||||
self._cloud_ice_servers_listener()
|
||||
self._cloud_ice_servers_listener = None
|
||||
|
||||
async def async_prefs_updated(prefs: CloudPreferences) -> None:
|
||||
updated_prefs = prefs.last_updated
|
||||
|
||||
if (
|
||||
updated_prefs is None
|
||||
or PREF_ENABLE_CLOUD_ICE_SERVERS not in updated_prefs
|
||||
):
|
||||
return
|
||||
|
||||
await async_register_cloud_ice_servers_listener(prefs)
|
||||
|
||||
await async_register_cloud_ice_servers_listener(self._prefs)
|
||||
|
||||
self._prefs.async_listen_updates(async_prefs_updated)
|
||||
|
||||
tasks = []
|
||||
|
||||
if self._prefs.alexa_enabled and self._prefs.alexa_report_state:
|
||||
@@ -242,8 +195,6 @@ class CloudClient(Interface):
|
||||
if self._prefs.google_enabled:
|
||||
tasks.append(enable_google)
|
||||
|
||||
tasks.append(setup_cloud_ice_servers)
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*(task(None) for task in tasks))
|
||||
|
||||
@@ -271,10 +222,6 @@ class CloudClient(Interface):
|
||||
self._google_config.async_deinitialize()
|
||||
self._google_config = None
|
||||
|
||||
if self._cloud_ice_servers_listener:
|
||||
self._cloud_ice_servers_listener()
|
||||
self._cloud_ice_servers_listener = None
|
||||
|
||||
@callback
|
||||
def user_message(self, identifier: str, title: str, message: str) -> None:
|
||||
"""Create a message for user to UI."""
|
||||
|
||||
@@ -43,7 +43,6 @@ PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
|
||||
PREF_TTS_DEFAULT_VOICE = "tts_default_voice"
|
||||
PREF_GOOGLE_CONNECTED = "google_connected"
|
||||
PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable"
|
||||
PREF_ENABLE_CLOUD_ICE_SERVERS = "cloud_ice_servers_enabled"
|
||||
DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "JennyNeural")
|
||||
DEFAULT_DISABLE_2FA = False
|
||||
DEFAULT_ALEXA_REPORT_STATE = True
|
||||
|
||||
@@ -42,7 +42,6 @@ from .const import (
|
||||
PREF_ALEXA_REPORT_STATE,
|
||||
PREF_DISABLE_2FA,
|
||||
PREF_ENABLE_ALEXA,
|
||||
PREF_ENABLE_CLOUD_ICE_SERVERS,
|
||||
PREF_ENABLE_GOOGLE,
|
||||
PREF_GOOGLE_REPORT_STATE,
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN,
|
||||
@@ -449,7 +448,6 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
|
||||
vol.Coerce(tuple), validate_language_voice
|
||||
),
|
||||
vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool,
|
||||
vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["hass_nabucasa"],
|
||||
"requirements": ["hass-nabucasa==0.83.0"],
|
||||
"requirements": ["hass-nabucasa==0.81.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ from .const import (
|
||||
PREF_CLOUD_USER,
|
||||
PREF_CLOUDHOOKS,
|
||||
PREF_ENABLE_ALEXA,
|
||||
PREF_ENABLE_CLOUD_ICE_SERVERS,
|
||||
PREF_ENABLE_GOOGLE,
|
||||
PREF_ENABLE_REMOTE,
|
||||
PREF_GOOGLE_CONNECTED,
|
||||
@@ -177,7 +176,6 @@ class CloudPreferences:
|
||||
google_settings_version: int | UndefinedType = UNDEFINED,
|
||||
google_connected: bool | UndefinedType = UNDEFINED,
|
||||
remote_allow_remote_enable: bool | UndefinedType = UNDEFINED,
|
||||
cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED,
|
||||
) -> None:
|
||||
"""Update user preferences."""
|
||||
prefs = {**self._prefs}
|
||||
@@ -200,7 +198,6 @@ class CloudPreferences:
|
||||
(PREF_REMOTE_DOMAIN, remote_domain),
|
||||
(PREF_GOOGLE_CONNECTED, google_connected),
|
||||
(PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable),
|
||||
(PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled),
|
||||
)
|
||||
if value is not UNDEFINED
|
||||
}
|
||||
@@ -249,7 +246,6 @@ class CloudPreferences:
|
||||
PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
|
||||
PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable,
|
||||
PREF_TTS_DEFAULT_VOICE: self.tts_default_voice,
|
||||
PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled,
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -366,14 +362,6 @@ class CloudPreferences:
|
||||
"""
|
||||
return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def cloud_ice_servers_enabled(self) -> bool:
|
||||
"""Return if cloud ICE servers are enabled."""
|
||||
cloud_ice_servers_enabled: bool = self._prefs.get(
|
||||
PREF_ENABLE_CLOUD_ICE_SERVERS, True
|
||||
)
|
||||
return cloud_ice_servers_enabled
|
||||
|
||||
async def get_cloud_user(self) -> str:
|
||||
"""Return ID of Home Assistant Cloud system user."""
|
||||
user = await self._load_cloud_user()
|
||||
@@ -421,7 +409,6 @@ class CloudPreferences:
|
||||
PREF_ENABLE_ALEXA: True,
|
||||
PREF_ENABLE_GOOGLE: True,
|
||||
PREF_ENABLE_REMOTE: False,
|
||||
PREF_ENABLE_CLOUD_ICE_SERVERS: True,
|
||||
PREF_GOOGLE_CONNECTED: False,
|
||||
PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS,
|
||||
PREF_GOOGLE_ENTITY_CONFIGS: {},
|
||||
|
||||
@@ -33,7 +33,6 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
data["remote_connected"] = cloud.remote.is_connected
|
||||
data["alexa_enabled"] = client.prefs.alexa_enabled
|
||||
data["google_enabled"] = client.prefs.google_enabled
|
||||
data["cloud_ice_servers_enabled"] = client.prefs.cloud_ice_servers_enabled
|
||||
data["remote_server"] = cloud.remote.snitun_server
|
||||
data["certificate_status"] = cloud.remote.certificate_status
|
||||
data["instance_id"] = client.prefs.instance_id
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"]
|
||||
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.10.2"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/deako",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydeako"],
|
||||
"requirements": ["pydeako==0.5.4"],
|
||||
"requirements": ["pydeako==0.4.0"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_deako._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ from pydeconz.utils import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import ssdp
|
||||
from homeassistant.components.hassio import HassioServiceInfo
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_HASSIO,
|
||||
ConfigEntry,
|
||||
@@ -30,7 +31,6 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
||||
|
||||
from .const import (
|
||||
CONF_ALLOW_CLIP_SENSOR,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"conversation",
|
||||
"dhcp",
|
||||
"energy",
|
||||
"go2rtc",
|
||||
"history",
|
||||
"homeassistant_alerts",
|
||||
"logbook",
|
||||
|
||||
@@ -75,21 +75,6 @@ async def async_setup_entry(
|
||||
support_release_notes=True,
|
||||
release_url="https://www.example.com/release/1.93.3",
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
update_steps=10,
|
||||
),
|
||||
DemoUpdate(
|
||||
unique_id="update_support_decimal_progress",
|
||||
device_name="Demo Update with Decimal Progress",
|
||||
title="Philips Lamps Firmware",
|
||||
installed_version="1.93.3",
|
||||
latest_version="1.94.2",
|
||||
support_progress=True,
|
||||
release_summary="Added support for effects",
|
||||
support_release_notes=True,
|
||||
release_url="https://www.example.com/release/1.93.3",
|
||||
device_class=UpdateDeviceClass.FIRMWARE,
|
||||
display_precision=2,
|
||||
update_steps=1000,
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -121,13 +106,10 @@ class DemoUpdate(UpdateEntity):
|
||||
support_install: bool = True,
|
||||
support_release_notes: bool = False,
|
||||
device_class: UpdateDeviceClass | None = None,
|
||||
display_precision: int = 0,
|
||||
update_steps: int = 100,
|
||||
) -> None:
|
||||
"""Initialize the Demo select entity."""
|
||||
self._attr_installed_version = installed_version
|
||||
self._attr_device_class = device_class
|
||||
self._attr_display_precision = display_precision
|
||||
self._attr_latest_version = latest_version
|
||||
self._attr_release_summary = release_summary
|
||||
self._attr_release_url = release_url
|
||||
@@ -137,7 +119,6 @@ class DemoUpdate(UpdateEntity):
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=device_name,
|
||||
)
|
||||
self._update_steps = update_steps
|
||||
if support_install:
|
||||
self._attr_supported_features |= (
|
||||
UpdateEntityFeature.INSTALL
|
||||
@@ -155,14 +136,12 @@ class DemoUpdate(UpdateEntity):
|
||||
) -> None:
|
||||
"""Install an update."""
|
||||
if self.supported_features & UpdateEntityFeature.PROGRESS:
|
||||
self._attr_in_progress = True
|
||||
for progress in range(0, self._update_steps, 1):
|
||||
self._attr_update_percentage = progress / (self._update_steps / 100)
|
||||
for progress in range(0, 100, 10):
|
||||
self._attr_in_progress = progress
|
||||
self.async_write_ha_state()
|
||||
await _fake_install()
|
||||
|
||||
self._attr_in_progress = False
|
||||
self._attr_update_percentage = None
|
||||
self._attr_installed_version = (
|
||||
version if version is not None else self.latest_version
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import Semaphore
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -33,7 +32,7 @@ from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONNECTED_PLC_DEVICES,
|
||||
@@ -48,7 +47,6 @@ from .const import (
|
||||
SWITCH_GUEST_WIFI,
|
||||
SWITCH_LEDS,
|
||||
)
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,7 +58,7 @@ class DevoloHomeNetworkData:
|
||||
"""The devolo Home Network data."""
|
||||
|
||||
device: Device
|
||||
coordinators: dict[str, DevoloDataUpdateCoordinator[Any]]
|
||||
coordinators: dict[str, DataUpdateCoordinator[Any]]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -70,7 +68,6 @@ async def async_setup_entry(
|
||||
zeroconf_instance = await zeroconf.async_get_async_instance(hass)
|
||||
async_client = get_async_client(hass)
|
||||
device_registry = dr.async_get(hass)
|
||||
semaphore = Semaphore(1)
|
||||
|
||||
try:
|
||||
device = Device(
|
||||
@@ -166,72 +163,58 @@ async def async_setup_entry(
|
||||
"""Disconnect from device."""
|
||||
await device.async_disconnect()
|
||||
|
||||
coordinators: dict[str, DevoloDataUpdateCoordinator[Any]] = {}
|
||||
coordinators: dict[str, DataUpdateCoordinator[Any]] = {}
|
||||
if device.plcnet:
|
||||
coordinators[CONNECTED_PLC_DEVICES] = DevoloDataUpdateCoordinator(
|
||||
coordinators[CONNECTED_PLC_DEVICES] = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=CONNECTED_PLC_DEVICES,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_connected_plc_devices,
|
||||
update_interval=LONG_UPDATE_INTERVAL,
|
||||
)
|
||||
if device.device and "led" in device.device.features:
|
||||
coordinators[SWITCH_LEDS] = DevoloDataUpdateCoordinator(
|
||||
coordinators[SWITCH_LEDS] = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=SWITCH_LEDS,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_led_status,
|
||||
update_interval=SHORT_UPDATE_INTERVAL,
|
||||
)
|
||||
if device.device and "restart" in device.device.features:
|
||||
coordinators[LAST_RESTART] = DevoloDataUpdateCoordinator(
|
||||
coordinators[LAST_RESTART] = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=LAST_RESTART,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_last_restart,
|
||||
update_interval=SHORT_UPDATE_INTERVAL,
|
||||
)
|
||||
if device.device and "update" in device.device.features:
|
||||
coordinators[REGULAR_FIRMWARE] = DevoloDataUpdateCoordinator(
|
||||
coordinators[REGULAR_FIRMWARE] = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=REGULAR_FIRMWARE,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_firmware_available,
|
||||
update_interval=FIRMWARE_UPDATE_INTERVAL,
|
||||
)
|
||||
if device.device and "wifi1" in device.device.features:
|
||||
coordinators[CONNECTED_WIFI_CLIENTS] = DevoloDataUpdateCoordinator(
|
||||
coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=CONNECTED_WIFI_CLIENTS,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_wifi_connected_station,
|
||||
update_interval=SHORT_UPDATE_INTERVAL,
|
||||
)
|
||||
coordinators[NEIGHBORING_WIFI_NETWORKS] = DevoloDataUpdateCoordinator(
|
||||
coordinators[NEIGHBORING_WIFI_NETWORKS] = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=NEIGHBORING_WIFI_NETWORKS,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_wifi_neighbor_access_points,
|
||||
update_interval=LONG_UPDATE_INTERVAL,
|
||||
)
|
||||
coordinators[SWITCH_GUEST_WIFI] = DevoloDataUpdateCoordinator(
|
||||
coordinators[SWITCH_GUEST_WIFI] = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=SWITCH_GUEST_WIFI,
|
||||
semaphore=semaphore,
|
||||
update_method=async_update_guest_wifi_status,
|
||||
update_interval=SHORT_UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
@@ -15,13 +15,13 @@ from homeassistant.components.binary_sensor import (
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool:
|
||||
@@ -78,7 +78,7 @@ class DevoloBinarySensorEntity(
|
||||
def __init__(
|
||||
self,
|
||||
entry: DevoloHomeNetworkConfigEntry,
|
||||
coordinator: DevoloDataUpdateCoordinator[LogicalNetwork],
|
||||
coordinator: DataUpdateCoordinator[LogicalNetwork],
|
||||
description: DevoloBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
|
||||
@@ -22,7 +22,7 @@ from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS
|
||||
from .entity import DevoloEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Base coordinator."""
|
||||
|
||||
from asyncio import Semaphore
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from logging import Logger
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
|
||||
class DevoloDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
"""Class to manage fetching data from devolo Home Network devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
logger: Logger,
|
||||
*,
|
||||
config_entry: ConfigEntry,
|
||||
name: str,
|
||||
semaphore: Semaphore,
|
||||
update_interval: timedelta,
|
||||
update_method: Callable[[], Awaitable[_DataT]],
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger,
|
||||
config_entry=config_entry,
|
||||
name=name,
|
||||
update_interval=update_interval,
|
||||
update_method=update_method,
|
||||
)
|
||||
self._semaphore = semaphore
|
||||
|
||||
async def _async_update_data(self) -> _DataT:
|
||||
"""Fetch the latest data from the source."""
|
||||
async with self._semaphore:
|
||||
return await super()._async_update_data()
|
||||
@@ -13,13 +13,15 @@ from homeassistant.const import STATE_UNKNOWN, UnitOfFrequency
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import CONNECTED_WIFI_CLIENTS, DOMAIN, WIFI_APTYPE, WIFI_BANDS
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -29,7 +31,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Get all devices and sensors and setup them via config entry."""
|
||||
device = entry.runtime_data.device
|
||||
coordinators: dict[str, DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]] = (
|
||||
coordinators: dict[str, DataUpdateCoordinator[list[ConnectedStationInfo]]] = (
|
||||
entry.runtime_data.coordinators
|
||||
)
|
||||
registry = er.async_get(hass)
|
||||
@@ -81,16 +83,14 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
# The pylint disable is needed because of https://github.com/pylint-dev/pylint/issues/9138
|
||||
class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
|
||||
CoordinatorEntity[DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]],
|
||||
ScannerEntity,
|
||||
class DevoloScannerEntity(
|
||||
CoordinatorEntity[DataUpdateCoordinator[list[ConnectedStationInfo]]], ScannerEntity
|
||||
):
|
||||
"""Representation of a devolo device tracker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DevoloDataUpdateCoordinator[list[ConnectedStationInfo]],
|
||||
coordinator: DataUpdateCoordinator[list[ConnectedStationInfo]],
|
||||
device: Device,
|
||||
mac: str,
|
||||
) -> None:
|
||||
|
||||
@@ -12,11 +12,13 @@ from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork
|
||||
from homeassistant.const import ATTR_CONNECTIONS
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
|
||||
type _DataType = (
|
||||
LogicalNetwork
|
||||
@@ -62,14 +64,14 @@ class DevoloEntity(Entity):
|
||||
|
||||
|
||||
class DevoloCoordinatorEntity[_DataT: _DataType](
|
||||
CoordinatorEntity[DevoloDataUpdateCoordinator[_DataT]], DevoloEntity
|
||||
CoordinatorEntity[DataUpdateCoordinator[_DataT]], DevoloEntity
|
||||
):
|
||||
"""Representation of a coordinated devolo home network device."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: DevoloHomeNetworkConfigEntry,
|
||||
coordinator: DevoloDataUpdateCoordinator[_DataT],
|
||||
coordinator: DataUpdateCoordinator[_DataT],
|
||||
) -> None:
|
||||
"""Initialize a devolo home network device."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -13,14 +13,14 @@ from homeassistant.components.image import ImageEntity, ImageEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -66,7 +66,7 @@ class DevoloImageEntity(DevoloCoordinatorEntity[WifiGuestAccessGet], ImageEntity
|
||||
def __init__(
|
||||
self,
|
||||
entry: DevoloHomeNetworkConfigEntry,
|
||||
coordinator: DevoloDataUpdateCoordinator[WifiGuestAccessGet],
|
||||
coordinator: DataUpdateCoordinator[WifiGuestAccessGet],
|
||||
description: DevoloImageEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from devolo_plc_api.device_api import ConnectedStationInfo, NeighborAPInfo
|
||||
from devolo_plc_api.plcnet_api import REMOTE, DataRate, LogicalNetwork
|
||||
@@ -20,6 +20,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import EntityCategory, UnitOfDataRate
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
@@ -31,10 +32,9 @@ from .const import (
|
||||
PLC_RX_RATE,
|
||||
PLC_TX_RATE,
|
||||
)
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
def _last_restart(runtime: int) -> datetime:
|
||||
@@ -47,10 +47,26 @@ def _last_restart(runtime: int) -> datetime:
|
||||
)
|
||||
|
||||
|
||||
type _CoordinatorDataType = (
|
||||
LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo] | int
|
||||
_CoordinatorDataT = TypeVar(
|
||||
"_CoordinatorDataT",
|
||||
bound=LogicalNetwork
|
||||
| DataRate
|
||||
| list[ConnectedStationInfo]
|
||||
| list[NeighborAPInfo]
|
||||
| int,
|
||||
)
|
||||
_ValueDataT = TypeVar(
|
||||
"_ValueDataT",
|
||||
bound=LogicalNetwork
|
||||
| DataRate
|
||||
| list[ConnectedStationInfo]
|
||||
| list[NeighborAPInfo]
|
||||
| int,
|
||||
)
|
||||
_SensorDataT = TypeVar(
|
||||
"_SensorDataT",
|
||||
bound=int | float | datetime,
|
||||
)
|
||||
type _SensorDataType = int | float | datetime
|
||||
|
||||
|
||||
class DataRateDirection(StrEnum):
|
||||
@@ -61,10 +77,9 @@ class DataRateDirection(StrEnum):
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class DevoloSensorEntityDescription[
|
||||
_CoordinatorDataT: _CoordinatorDataType,
|
||||
_SensorDataT: _SensorDataType,
|
||||
](SensorEntityDescription):
|
||||
class DevoloSensorEntityDescription(
|
||||
SensorEntityDescription, Generic[_CoordinatorDataT, _SensorDataT]
|
||||
):
|
||||
"""Describes devolo sensor entity."""
|
||||
|
||||
value_func: Callable[[_CoordinatorDataT], _SensorDataT]
|
||||
@@ -185,11 +200,8 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BaseDevoloSensorEntity[
|
||||
_CoordinatorDataT: _CoordinatorDataType,
|
||||
_ValueDataT: _CoordinatorDataType,
|
||||
_SensorDataT: _SensorDataType,
|
||||
](
|
||||
class BaseDevoloSensorEntity(
|
||||
Generic[_CoordinatorDataT, _ValueDataT, _SensorDataT],
|
||||
DevoloCoordinatorEntity[_CoordinatorDataT],
|
||||
SensorEntity,
|
||||
):
|
||||
@@ -198,7 +210,7 @@ class BaseDevoloSensorEntity[
|
||||
def __init__(
|
||||
self,
|
||||
entry: DevoloHomeNetworkConfigEntry,
|
||||
coordinator: DevoloDataUpdateCoordinator[_CoordinatorDataT],
|
||||
coordinator: DataUpdateCoordinator[_CoordinatorDataT],
|
||||
description: DevoloSensorEntityDescription[_ValueDataT, _SensorDataT],
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
@@ -206,11 +218,9 @@ class BaseDevoloSensorEntity[
|
||||
super().__init__(entry, coordinator)
|
||||
|
||||
|
||||
class DevoloSensorEntity[
|
||||
_CoordinatorDataT: _CoordinatorDataType,
|
||||
_ValueDataT: _CoordinatorDataType,
|
||||
_SensorDataT: _SensorDataType,
|
||||
](BaseDevoloSensorEntity[_CoordinatorDataT, _ValueDataT, _SensorDataT]):
|
||||
class DevoloSensorEntity(
|
||||
BaseDevoloSensorEntity[_CoordinatorDataT, _CoordinatorDataT, _SensorDataT]
|
||||
):
|
||||
"""Representation of a generic devolo sensor."""
|
||||
|
||||
entity_description: DevoloSensorEntityDescription[_CoordinatorDataT, _SensorDataT]
|
||||
@@ -231,7 +241,7 @@ class DevoloPlcDataRateSensorEntity(
|
||||
def __init__(
|
||||
self,
|
||||
entry: DevoloHomeNetworkConfigEntry,
|
||||
coordinator: DevoloDataUpdateCoordinator[LogicalNetwork],
|
||||
coordinator: DataUpdateCoordinator[LogicalNetwork],
|
||||
description: DevoloSensorEntityDescription[DataRate, float],
|
||||
peer: str,
|
||||
) -> None:
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from devolo_plc_api.device import Device
|
||||
from devolo_plc_api.device_api import WifiGuestAccessGet
|
||||
@@ -15,19 +15,19 @@ from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
type _DataType = WifiGuestAccessGet | bool
|
||||
_DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class DevoloSwitchEntityDescription[_DataT: _DataType](SwitchEntityDescription):
|
||||
class DevoloSwitchEntityDescription(SwitchEntityDescription, Generic[_DataT]):
|
||||
"""Describes devolo switch entity."""
|
||||
|
||||
is_on_func: Callable[[_DataT], bool]
|
||||
@@ -81,9 +81,7 @@ async def async_setup_entry(
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class DevoloSwitchEntity[_DataT: _DataType](
|
||||
DevoloCoordinatorEntity[_DataT], SwitchEntity
|
||||
):
|
||||
class DevoloSwitchEntity(DevoloCoordinatorEntity[_DataT], SwitchEntity):
|
||||
"""Representation of a devolo switch."""
|
||||
|
||||
entity_description: DevoloSwitchEntityDescription[_DataT]
|
||||
@@ -91,7 +89,7 @@ class DevoloSwitchEntity[_DataT: _DataType](
|
||||
def __init__(
|
||||
self,
|
||||
entry: DevoloHomeNetworkConfigEntry,
|
||||
coordinator: DevoloDataUpdateCoordinator[_DataT],
|
||||
coordinator: DataUpdateCoordinator[_DataT],
|
||||
description: DevoloSwitchEntityDescription[_DataT],
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
|
||||
@@ -20,13 +20,13 @@ from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from . import DevoloHomeNetworkConfigEntry
|
||||
from .const import DOMAIN, REGULAR_FIRMWARE
|
||||
from .coordinator import DevoloDataUpdateCoordinator
|
||||
from .entity import DevoloCoordinatorEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -79,7 +79,7 @@ class DevoloUpdateEntity(DevoloCoordinatorEntity, UpdateEntity):
|
||||
def __init__(
|
||||
self,
|
||||
entry: DevoloHomeNetworkConfigEntry,
|
||||
coordinator: DevoloDataUpdateCoordinator,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
description: DevoloUpdateEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize entity."""
|
||||
|
||||
@@ -46,7 +46,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator = DataUpdateCoordinator[GlucoseReading](
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_method=async_update_data,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/doorbird",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["doorbirdpy"],
|
||||
"requirements": ["DoorBirdPy==3.0.8"],
|
||||
"requirements": ["DoorBirdPy==3.0.4"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_axis-video._tcp.local.",
|
||||
|
||||
@@ -69,7 +69,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=lock.name,
|
||||
update_method=_async_update,
|
||||
update_interval=timedelta(seconds=UPDATE_SECONDS),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user